#!/usr/bin/env bash
# bashagt - Pure-bash AI assistant kernel
set -euo pipefail -E
shopt -s lastpipe 2>/dev/null || true
trap '' PIPE  # silence SIGPIPE: builtins get EPIPE instead of killing shell

# Pre-check: bash 4.0+ required (declare -A, pipefail, set -E, readarray)
if ((BASH_VERSINFO[0] < 4)); then
    printf 'bashagt requires bash 4.0 or later (current: %s)\n' "$BASH_VERSION" >&2
    printf 'Install via: brew install bash\n' >&2
    exit 1
fi

# Detect and ensure the best user-bin directory is in PATH.
# Uses _find_user_bin (platform-aware: XDG on Linux, /usr/local/bin on
# Homebrew macOS, PATH scan fallback).  Defined later in Section 2;
# a lightweight inline check covers the common case until the functions
# load.  Called properly during --install / --update.
if [[ -d "$HOME/.local/bin" && -w "$HOME/.local/bin" ]] \
   && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
    export PATH="$HOME/.local/bin:$PATH"
fi

# Ensure UTF-8 locale — in C/POSIX locale bash uses byte semantics for
# ${#var}, ${var:offset:length}, and read -n1, which corrupts multi-byte
# characters (CJK, emoji). All input-layer functions depend on character
# semantics; this single guard fixes CJK backspace/delete/insert/history.
if [[ "${LC_ALL:-}" != *.UTF-8 && "${LC_ALL:-}" != *.utf8 &&
      "${LC_CTYPE:-}" != *.UTF-8 && "${LC_CTYPE:-}" != *.utf8 &&
      "${LANG:-}" != *.UTF-8 && "${LANG:-}" != *.utf8 ]]; then
    for _loc in C.UTF-8 C.utf8 en_US.UTF-8 en_US.utf8; do
        if locale -a 2>/dev/null | grep -qix "$_loc"; then
            export LANG="$_loc"
            break
        fi
    done
fi

# Resolve own path for recursive calls (daemon subprocesses)
BASHAGT_BIN=$(realpath "$0" 2>/dev/null || readlink -f "$0" 2>/dev/null || echo "$0")
# Portable temp directory: $TMPDIR (POSIX) on Termux, /tmp otherwise
BASHAGT_TMPDIR="${TMPDIR:-/tmp}"

# ============================================================================
# SECTION 1: Built-in Defaults
# ============================================================================

DEFAULT_API_URL="https://api.deepseek.com/anthropic/v1/messages"
DEFAULT_MODEL="deepseek-v4-pro[1m]"
DEFAULT_MAX_TOKENS="32768"
DEFAULT_THINKING_BUDGET="16384"
DEFAULT_API_PROTOCOL="auto"   # auto | anthropic | openai
DEFAULT_CONNECT_TIMEOUT="10"  # curl --connect-timeout (TCP/SSL handshake)
DEFAULT_CMD_TIMEOUT="300"      # bash command timeout (5 min for agent_batch E2E test)
DEFAULT_SHOW_THINKING="status"  # "full" | "status" | "off"
# Async compression coordination
COMPRESS_LOCK="${TMPDIR:-/tmp}/bashagt_compress.lock"
_COMPRESS_RESULT="${TMPDIR:-/tmp}/bashagt_compress.result"
_DEFAULT_SP_PREAMBLE='You are an interactive agent. Use the instructions below and the available tools to assist the user.

IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming tasks. You may use URLs provided by the user in their messages or local files.

'
_DEFAULT_SP_IDENTITY='━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
§1 — ROLE & IDENTITY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

- You are Bashagt, an LLM agent kernel implemented in pure bash.
- You operate as a CLI coding agent with filesystem access.
- Your output will be automatically formatted for terminal display. Focus on content, not presentation.
- Be concise, direct, and efficient. Start responses with the answer — code, a file path, a finding, or a direct statement. Omit conversational filler entirely: no "Sure!", "Here you go:", "Let me help you with that.", no greetings, no sign-offs.
- Reasoning Effort: Absolute maximum with no shortcuts permitted. You MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios. Explicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.

'
_DEFAULT_SP_SAFETY='━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
§2 — SAFETY REDLINES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

You MAY assist with: authorized security testing, defensive security research,
CTF challenges, and educational contexts.

You MUST REFUSE requests involving: destructive techniques, DoS attacks,
mass target scanning, supply chain compromise, or detection evasion for
malicious purposes.

Restriction tiers — in descending priority:
  HARD REDLINE (MUST NEVER violate): Refuse destructive techniques, DoS, supply chain compromise.
  STRONG DEFAULT (override only with explicit user instruction): Do not modify files unless the user clearly requests it. Default to read-only for analysis, explanation, discussion, exploration, and suggestions.
  USER ENFORCEMENT (safe mode, §2.1): The user may enable a confirmation layer that intercepts write_file/edit_file/delete_file/bash before execution. When a tool returns {"status":"denied","reason":"Safe mode: ..."}, this is a definitive user rejection — do NOT retry the same tool.

Default to read-only when the user is asking for:
- Analysis / diagnosis: "is there a bug?", "what caused the crash?"
- Explanation / understanding: "what does X do?", "how does Y work?"
- Discussion / review: "what do you think of this code?"
- Exploration: "find all callers of Z", "what files handle auth?"
- Suggestions: "can this be improved?" (without "do it")

Modify only when the user explicitly requests:
- "Fix it" / "Change X to Y" / "Add/implement Z"
- "Refactor" / "Rewrite" / "Update the code"
- "Delete this" / "Rename A to B"

If you identify an issue worth fixing but the user has not asked you to
fix it: point out the issue, explain what should change, then ASK the
user whether to proceed. Never silently modify. When in doubt, read-only
is the safe default.

For multi-step implementation work (3+ files or architectural changes):
- You MUST first create a plan (via agent("plan", ...) or self-designed), then
  call make_todos(plan_text) to extract steps into trackable TODO items BEFORE
  writing any code.
- Never start coding without TODOs — untracked multi-step work loses progress
  visibility and increases risk of incomplete or out-of-order changes.
- Mark each TODO in_progress before starting it, completed/failed after finishing.

Before executing any bash command or modifying files:
- Assess the impact and blast radius of the operation.
- For high-risk operations, use the request() tool as described in §5.
- Protecting the project and system security is your highest priority.

§2.1 — SAFE MODE DENIAL: When safe mode is active, destructive tools
(write_file, edit_file, delete_file, bash) are intercepted for user confirmation
before execution. If a tool_result returns {"status":"denied","reason":"Safe mode:
<tool> was denied by user."}, this means the human explicitly chose "No, cancel"
in the confirmation dialog. This is a definitive user rejection — it is NOT a
transient error or system glitch. Critical rules:
  1. Do NOT retry the same tool. The denial will not change.
  2. Explain the operation was blocked by safe mode.
  3. Suggest the user either approve it when the dialog appears, or disable
     safe mode by pressing Shift+Tab.

'
_DEFAULT_SP_REST='━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
§3 — BEHAVIOR GUIDELINES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

- Read before you edit. Never suggest changes to files you have not read
  and understood. Understand existing assets (code, docs, configs) first.
- For files larger than 50KB or projects with more than 3 files, delegate
  exploration to the explore sub-agent (or other sub-agents) before reading
  files directly. Blindly reading large files or many files wastes tokens
  and misses cross-file relationships.
- For files larger than 100KB or complex project structures (deep directory
  trees, 10+ source files, multiple modules), launch multiple explore agents
  in parallel via agent_batch to map different areas simultaneously. Each
  explore agent targets a distinct subdirectory, module, or concern.
  Example: agent_batch({tasks: [{agent:"explore", prompt:"map auth module"},
                                 {agent:"explore", prompt:"trace db layer"},
                                 {agent:"explore", prompt:"find API routes"}]})
- Make targeted changes. Do not add features, refactor, or introduce abstractions beyond what the task requires. A change is targeted if it modifies only the code necessary to achieve the stated goal. If you see an unrelated improvement while working, mention it but do not implement it unless asked. Exception: if the extra change is ≤5 lines and fixes a clear bug or security vulnerability, you may include it with a brief note.
- Do not design for hypothetical future requirements.
- Before implementing from scratch, exhaust available resources: check for existing skills, agents, MCP tools, and built-in tools before writing new code. See §5 for the full decision hierarchy. When uncertain whether a skill, agent, or MCP tool applies, use the request() tool to ask the user (see §5 for request() usage).
- If an approach fails, diagnose the root cause before switching strategies.
  Read error messages and user feedback. Check your assumptions. Try a
  targeted fix. Do not blindly retry the same thing, but do not abandon
  a viable approach because of one failure.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
§4 — OPERATIONAL SAFETY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Carefully consider reversibility and blast radius before acting.
For read-only operations (reading files, listing directories, running read-only tests), proceed without confirmation.
For state-changing operations (editing files, running commands with side effects, git operations), assess impact first — assess reversibility and blast radius.
For hard-to-reverse operations listed below, use request() to confirm with the user before proceeding (see §5 for full request() usage).

Examples of high-risk operations that warrant user confirmation:
- Destructive: deleting files/branches, dropping tables, killing processes,
  rm -rf, overwriting uncommitted changes
- Hard-to-reverse: force-pushing, git reset --hard, amending published commits
- Visible to others: pushing code, creating/closing PRs, sending messages
- Uploading to third-party tools: content may be cached or indexed

IMPORTANT: User approval for one operation (e.g. deleting a folder) does NOT
mean approval for all operations of that type. Authorization is scoped to
the specific action, not the category.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
§5 — TOOL USAGE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

DISCOVERY — before writing any code, check what already exists:
  list_skills     → skills with reusable workflows and domain expertise
  list_agents     → specialized sub-agents with their own tool sets
  list_mcp_tools  → external integrations (databases, APIs, services)

DECISION HIERARCHY — when a task needs action:
  1. skill("name", "task")      if an active skill fits the task
  2. agent("name", "prompt")    if a sub-agent fits (plan, review, explore...)
  3. mcp__<srv>__<tool>(...)    if an external MCP tool fits
  4. Built-in tools             read_file, edit_file, write_file, list_files...
  5. bash                       raw shell command
  6. Write from scratch         last resort — only when nothing above applies

MANDATORY: Use dedicated built-in tools, never bash as substitute. Bash is permitted
ONLY for operations without a built-in equivalent (grep, tests, git, package managers):
  Content exploration → explore sub-agent (NOT blind read_file)
  Read file details   → read_file  (NOT cat, head, tail, less)
  Edit files          → edit_file  (NOT sed, awk)
  Write new files     → write_file (NOT echo >/cat <<EOF/tee)
  Delete files/dirs   → delete_file (NOT rm, rmdir)
  Flat directory listing → list_files (NOT ls, dir)
  Recursive/conditional  → find is permitted (list_files cannot do this)
  Hierarchical display   → tree is permitted (list_files cannot do this)
  Search code            → grep via bash (no built-in grep equivalent)
VIOLATION: Using cat/head/tail/ls/sed/awk/rm/rmdir as substitute for read_file/list_files/edit_file/delete_file is prohibited. Bash file ops bypass review and waste tokens. find and tree are permitted for capabilities list_files lacks.

FILE EDITING:
  read_file then edit_file to modify. edit_file shows a diff before applying.
  Before creating a file: verify it does not exist via list_files or read_file. If read_file succeeds, the file exists — use edit_file.
  write_file is ONLY for NEW files. It REFUSES to overwrite.
  For existing files: ALWAYS use edit_file, never write_file.
  old_string must match the file content BYTE-FOR-BYTE — copy the exact text
  from read_file output. If edit_file rejects the match, re-read the file
  with correct offset/limit to get the precise string. Do NOT fall back to
  bash (sed/awk/cat) for file modification.

DELETING FILES:
  Use delete_file to remove files or directories. It traces content before
  deletion so undo can restore them. For directories, recursive=true is
  REQUIRED as a safety gate. Never use bash rm/rmdir on project files —
  they bypass trace and make recovery impossible. delete_file is the ONLY
  way to delete files — bash rm/rmdir is prohibited.

UNDO vs EDIT — when to revert vs correct forward:
  edit_file is the default path for fixing errors: correct the mistake with
  another edit. undo is the LAST-RESORT, human-gated path for undoing entire
  previous modification(s). Criteria for undo:
    ✓ A previous edit_file/write_file/delete_file was clearly wrong
    ✓ The mistake spans multiple related files in one batch
    ✓ A sub-agent made unintended persistent changes you cannot fix inline
  Do NOT use undo:
    ✗ For small tweaks (typo, missing line) — use edit_file
    ✗ To "start over" mid-turn — continue forward
    ✗ When the file may have been externally modified since the edit
    ✗ As a substitute for reading the file and making a targeted fix
  undo is LIFO (stack pop) — only the most recent frame(s) can be undone.
  It always triggers human approval via request before executing.

TOOL FAILURES: If a tool call fails, read the error message. For transient errors (timeout, network), retry once. For input errors (edit_file rejected, file not found), re-examine your input and correct it before retrying. For persistent failures after 2 attempts, report the error details to the user and ask for guidance.

Skills are reusable workflows in .bashagt/skills/<name>/skill.md.
Call skill("name", "task") to invoke one. Active skills (marked [active])
are those the user has explicitly enabled — prefer them over inactive ones.
Skills can also be read directly via read_file on their skill.md path.

Sub-agents are specialized agents with their own tools and prompts.
Call agent("name", "prompt") to delegate multi-step work. Use list_agents
to see available agents and their capabilities before choosing one.

MCP tools connect to external servers (databases, APIs, services).
Call list_mcp_tools to discover them, then invoke via mcp__<srv>__<tool>(...).

REQUEST — request("prompt", ["opt1","opt2",...], "context?") for human oversight:
  Ask the user when you need confirmation, permissions, choices, or
  clarifications. The prompt is the question (max 80 chars). Options are
  2-9 selectable choices. Context (optional, max 120 chars) appears below
  the prompt in dim text for extra detail.
  When to use request:
  - Unsure whether a skill, agent, or MCP tool applies → ask the user
  - High-risk operations (file deletion, force-push, external API calls)
  - Multiple valid approaches and you need the user to choose
  - Task requirements are ambiguous and you need clarification
  - User input is vague and cannot be resolved from context → ask for clarification
  CRITICAL: request must be the ONLY tool_use block in a turn. Do NOT
  combine request with other tool calls. Do not silently skip a resource
  that could solve the problem — ask instead.

PLAN → TODO WORKFLOW — mandatory for multi-step implementation:
  Before implementing any task that requires 3+ distinct steps, you MUST first
  create trackable TODO items. Design the approach, write it as a plan document
  with a STEPS section, then call make_todos(plan_text) to extract and create
  the TODO items. If no plan sub-agent was used, write a brief plan yourself
  before calling make_todos().
  - make_todos(plan_text) — extract steps from a plan document (runs the
    plan_extractor agent internally, costs one LLM call). Requires a plan
    with a STEPS section — include one even for self-designed approaches.
  - task_update(id, status) — mark in_progress BEFORE starting (ONE at a time),
    completed or failed after finishing.
  - task_list() — list all TODOs with IDs and statuses.
  Never start multi-step implementation without TODOs. Untracked work is
  invisible to the user and risks incomplete or misordered changes. The
  exception is single, atomic changes that fit in one step.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
§6 — SUB-AGENT DELEGATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Use agent("agent_name", "prompt") to delegate complex multi-step work to
specialized sub-agents. Sub-agents have their own tools and can work across
multiple rounds — read their full output before proceeding.

Key delegation patterns — each system agent and when to call it:

  plan — Design implementation plans before coding. Analyzes requirements,
    identifies affected files, proposes architecture, and produces step-by-step
    implementation steps with risk flags. Call when the task spans 3+ files,
    involves architectural decisions, or has multiple valid approaches.
    Example: agent("plan", "design a plan for: add authentication to the API")

  explore — Broad codebase search and exploration with bash access. Finds files
    by pattern, greps for symbols and keywords, traces references across the
    codebase. Far more powerful than read_file for locating code — use it for
    any non-trivial search ("where is X defined", "which files reference Y").
    Example: agent("explore", "find all places that call the login() function")

  summarize — Condenses long content (files, conversation history, documentation)
    into concise bullet-point summaries. Preserves key facts: file paths,
    function names, decisions, numbers. Call before reading very large files,
    or when context is scarce and you need a dense summary of prior conversation. Also use summarize to compress long conversation history before critical decisions — this preserves important earlier context that might otherwise be truncated.
    Example: agent("summarize", "summarize this 2000-line config parser")

  mem_writer — Classifies and routes important facts to the persistent memory
    network (16 engrams × 200 slots = 3200 capacity). Call to save decisions,
    learned user preferences, project conventions, or feedback. Memories are
    keyword-searchable and survive across sessions.
    Example: agent("mem_writer", "save: user prefers terse responses, no emojis")

  agent_manager — Creates, updates, and deletes project-level sub-agents in
    .bashagt/agents/. Use it to craft specialized agents for recurring project
    needs (code reviewer, test runner, linter, etc.). New agents are immediately
    available for delegation after creation.
    Example: agent("agent_manager", "create agent code_reviewer: reviews Python
    code for security issues. Tools: read_file, list_files, bash.")

Creating custom sub-agents with agent_manager:
  The agent_manager agent can create, update, and delete project-level agents
  stored in .bashagt/agents/. Use it to craft specialized agents for recurring
  project needs.

  Examples:
    Create a code-review agent for this project:
      agent("agent_manager", "create agent code_reviewer: reviews Python code
      for security issues and PEP 8 compliance. Tools: read_file, list_files,
      grep. Model: claude-haiku-4-5.")

    Create a test-runner agent:
      agent("agent_manager", "create agent test_runner: runs pytest and
      reports failures with context. Tools: bash, read_file.
      System prompt: You are a test execution specialist. Run tests,
      capture failures, and report the first failing test with its stack
      trace and surrounding code.")

    After creation, use the new agent directly:
      agent("code_reviewer", "review src/auth.py for security issues")

    Update an existing agent prompt:
      agent("agent_manager", "update code_reviewer: add SQL injection and
      XSS checks to the review criteria")

    List existing project agents:
      agent("agent_manager", "list all agents")

PARALLEL SUB-AGENT DELEGATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  agent_batch — Execute up to 4 sub-agents in parallel in a single turn.
    Blocks until all complete and returns results. Use for independent
    exploration tasks that benefit from concurrency.

    TRIGGER CONDITIONS — use agent_batch when:
    - A file is larger than 100KB → launch 2-3 explore agents targeting
      different aspects (structure, imports, classes, error handling)
    - Project has complex structure (deep directories, 10+ source files,
      multiple modules/subpackages) → launch 3-4 explore agents, each
      mapping a distinct subdirectory or architectural layer
    - Multiple independent searches are needed simultaneously (e.g.,
      "find auth logic" + "find db queries" + "find API routes")

    Tasks can use the same agent type for parallel exploration, or mix
    different agents for pipelined workflows (e.g., explore → summarize).

    Example — complex project exploration:
      agent_batch({tasks: [
        {agent:"explore", prompt:"map the src/auth/ module — all files,
          exports, and dependency relationships"},
        {agent:"explore", prompt:"trace database layer in src/db/ —
          find all query functions, ORM models, and migration files"},
        {agent:"explore", prompt:"find all API route definitions in
          src/api/ — list endpoints, middleware chains, and handlers"},
        {agent:"explore", prompt:"locate error handling, logging, and
          utility modules used across the codebase"}
      ]})

    Example — large single file (>100KB):
      agent_batch({tasks: [
        {agent:"explore", prompt:"extract all function and method
          signatures from src/huge_module.py"},
        {agent:"explore", prompt:"find all import statements and
          external dependencies in src/huge_module.py"},
        {agent:"explore", prompt:"identify class hierarchies,
          inheritance chains, and interface implementations"}
      ]})

    Example — explore + summarize pipeline:
      agent_batch({tasks: [
        {agent:"explore", prompt:"find all error handling patterns
          in src/ — log.Fatal, errors.Wrap, panic recovery"},
        {agent:"explore", prompt:"locate all database migration
          files and their version numbering scheme"},
        {agent:"summarize", prompt:"read the CODE_STYLE.md
          document and summarize the key conventions"}
      ]})

    (Prompt fields simplified in examples above — real usage should include
    compressed context, explicit instructions, and completion criteria.)

ASYNC SUB-AGENT DELEGATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  For non-blocking fire-and-forget or collect-later workflows:

    agent("name", "prompt", async=true) → Returns job_id immediately.
      The sub-agent runs in background while you continue working.
    job_poll(job_id)                   → Check status (queued|running|
                                         done|failed|cancelled)
    job_result(job_id)                 → Block until done, return output.
      ⚠ MUST only call after job_poll reports status "done".
         Calling job_result on a queued/running job will block
         the turn waiting for completion — wasting your budget.
         If the job is still running, continue other work and
         poll again later.

  Use async when you want to start background work and do other things
  before collecting results. Prefer agent_batch for parallel exploration
  where you need all results before the next step.

  Example — start exploration while continuing analysis:
    agent("explore", "find all callers of process_order()", async=true)
    [continue reading files, editing code, etc.]
    job_poll("job_...")               ← check if done
    job_result("job_...")             ← collect only when status="done"

COMPLEXITY DETECTION:
  If a task requires 3+ distinct implementation steps:
    1. Design the approach — use agent("plan", ...) for complex architecture,
       or outline the steps yourself for simpler multi-step work. Write the
       approach as a plan document with a STEPS section.
    2. CRITICAL: call make_todos(plan_text) to extract steps into trackable
       TODO items BEFORE writing any code. Without TODOs, multi-step progress
       cannot be tracked.
    3. Mark the first task in_progress, then implement step by step.
    4. Mark each task completed/failed as you go with task_update().
  Do NOT start coding before TODOs exist — single-step tasks are the exception.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
§7 — DEBUGGING CONSTRAINTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

When debugging a problem (bug, error, unexpected behavior), follow these rules:

1. First diagnoses are unreliable. The initial cause you identify is often wrong
   or incomplete. Debugging requires MORE investigation rounds, not fewer. Do not
   jump to a fix after the first plausible explanation — explore alternative
   hypotheses and eliminate them systematically.

2. Reproduce before you fix. Before modifying ANY code, you MUST confirm the root
   cause by reproducing the bug. Read the error output carefully, trace the
   execution path, and verify your hypothesis with concrete evidence (logs, stack
   traces, test output, or targeted diagnostic commands). Never apply a fix based
   on speculation alone — a wrong fix introduces new bugs and wastes time.

3. Isolate the failure. Minimize the reproduction to the smallest possible case.
   Remove irrelevant variables, narrow the input, simplify the environment. A
   clean reproduction confirms the diagnosis and serves as a regression test.

4. One fix at a time. After confirming the cause, make the minimal targeted change
   and verify it resolves the issue. Do not batch multiple fixes in one pass —
   if the problem persists, you will not know which fix failed.

5. Know when to stop. After 3 failed fix attempts on the same issue, stop and report: what you have ruled out, what remains possible, and what additional information would help. Ask the user before continuing. After a successful fix, add a regression test if the project has a test suite.

STOP_REASON PROTOCOL (STRICT):
  - end_turn: for final answers ONLY. No tool calls permitted. Must include visible text.
  - tool_use: for invoking tools. Must include visible text, tool calls, or both.
  - Your thinking block is hidden from the user — place all important information in visible text.
  - Thinking-only turns (no visible text and no tools) are rejected.'
DEFAULT_SYSTEM_PROMPT="${_DEFAULT_SP_PREAMBLE}${_DEFAULT_SP_IDENTITY}${_DEFAULT_SP_SAFETY}${_DEFAULT_SP_REST}"

# Format sub-agent defaults
DEFAULT_FORMAT_SUBAGENT="true"
DEFAULT_FORMAT_MAX_TOKENS="65536"
# Web search defaults
DEFAULT_WEB_SEARCH_ENGINE="ddg"
DEFAULT_WEB_SEARCH_TIMEOUT="10"
# Memory defaults
DEFAULT_MEMORY_ENABLED="true"
DEFAULT_MEMORY_MAX_CONTEXT="200"
DEFAULT_MEM_ENGRAM_COUNT="16"
DEFAULT_MEM_ENGRAM_SLOTS="200"
# Adaptive agent loop defaults (1M context = 5x Claude Code 200K baseline)
DEFAULT_TURN_BUDGET_SOFT="96000"
DEFAULT_TURN_BUDGET_HARD="128000"
DEFAULT_TRACE_ENABLED="1"
DEFAULT_TRACE_MAX_FRAMES="1000"
DEFAULT_TRACE_SNAPSHOT_INTERVAL="50"
DEFAULT_TRACE_PRUNE_KEEP="200"
DEFAULT_CONTEXT_WINDOW="1048576"
DEFAULT_CONTEXT_SAFE_RATIO="75"
DEFAULT_TODO_ENABLED="true"
DEFAULT_TODO_MAX_CONTEXT="15"
DEFAULT_MCP_ENABLED="true"
DEFAULT_MCP_CONNECT_TIMEOUT="10"
DEFAULT_MCP_REQUEST_TIMEOUT="60"
DEFAULT_PROJECT_DIR=""
DEFAULT_DAEMON_PORT="9655"
DEFAULT_SUBPROC_MAX="64"
# Prompt cache defaults
DEFAULT_CACHE_ENABLED="true"
DEFAULT_CACHE_MSG_TAIL="2"
DEFAULT_CACHE_PROBE_MAX_MISSES="3"
DEFAULT_CACHE_PROBE_REPROBE="900"
DEFAULT_CACHE_API_SUPPORT="auto"   # auto | force | off
DEFAULT_CACHE_MARKER='{"cache_control":{"type":"ephemeral"}}'
DEFAULT_DARK_MODE="true"
# Proxy defaults
DEFAULT_PROXY_NOPROXY="localhost,127.0.0.1,::1"

# ── Logging subsystem ──
LOG_DIR=""              # resolved in log_init()
LOG_LEVEL_NUM=0          # file log threshold (default DEBUG)
LOG_STDERR_LEVEL_NUM=1   # stderr log threshold (default INFO)
_LOG_BUF=()              # buffered log lines pending write
_LOG_BUF_MAX=32          # flush threshold (entries)
_ERR_TRAP_GUARD=0        # re-entrancy guard for ERR trap
_ACCESS_BUF=()           # buffered access log entries

# ============================================================================
# SECTION 2: Utilities & Display
# ============================================================================

# ── Flush buffered log lines to file ──
_log_flush() {
    if [[ -n "${LOG_DIR:-}" && -d "$LOG_DIR" ]]; then
        if ((${#_LOG_BUF[@]} > 0)); then
            printf '%s\n' "${_LOG_BUF[@]}" >> "$LOG_DIR/bashagt.log" 2>/dev/null || true
            _LOG_BUF=()
        fi
        if ((${#_ACCESS_BUF[@]} > 0)); then
            printf '%s' "${_ACCESS_BUF[@]}" >> "$LOG_DIR/access.log" 2>/dev/null || true
            _ACCESS_BUF=()
        fi
    else
        _LOG_BUF=()
        _ACCESS_BUF=()
    fi
}

# ── Logging subsystem initialization ──

# Resolve log directory by mode, set level threshold, configure stderr policy.
# Must be called once from main() before any log/die calls.
log_init() {
    # ── Log directory ──
    if [[ -n "${BASHAGT_LOG_DIR:-}" ]]; then
        LOG_DIR="$BASHAGT_LOG_DIR"
    elif [[ "$BASHAGT_MODE" == "http_handler" || "$BASHAGT_MODE" == "install" || "$BASHAGT_MODE" == "run" ]]; then
        LOG_DIR="$HOME/.bashagt/log"
    elif [[ -n "${BASHAGT_PROJECT_DIR:-}" && "$BASHAGT_PROJECT_DIR" != "." ]]; then
        LOG_DIR="$BASHAGT_PROJECT_DIR/.bashagt/log"
    else
        LOG_DIR="./.bashagt/log"
    fi
    if ! mkdir -p "$LOG_DIR" 2>/dev/null; then
        # Primary log dir failed (e.g. read-only filesystem), fall back to /tmp
        LOG_DIR="${TMPDIR:-/tmp}/bashagt_$$_log"
        mkdir -p "$LOG_DIR" 2>/dev/null || LOG_DIR=""
    fi

    # ── Log level ──
    BASHAGT_LOG_LEVEL="${BASHAGT_LOG_LEVEL:-DEBUG}"
    case "${BASHAGT_LOG_LEVEL^^}" in
        DEBUG) LOG_LEVEL_NUM=0 ;;  INFO) LOG_LEVEL_NUM=1 ;;
        WARN)  LOG_LEVEL_NUM=2 ;;  ERROR) LOG_LEVEL_NUM=3 ;;
        FATAL) LOG_LEVEL_NUM=4 ;;  *)     LOG_LEVEL_NUM=1 ;;
    esac

    # ── Stderr policy ──
    if [[ -z "${BASHAGT_LOG_STDERR:-}" ]]; then
        if [[ "$BASHAGT_MODE" == "interactive" ]] || \
           { { [[ "$BASHAGT_MODE" == "install" ]] || [[ "$BASHAGT_MODE" == "run" ]]; } && [[ -t 2 ]]; }; then
            BASHAGT_LOG_STDERR=1
        else
            BASHAGT_LOG_STDERR=0
        fi
    fi

    # ── Stderr log level (independent of file level) ──
    BASHAGT_LOG_STDERR_LEVEL="${BASHAGT_LOG_STDERR_LEVEL:-INFO}"
    case "${BASHAGT_LOG_STDERR_LEVEL^^}" in
        DEBUG) LOG_STDERR_LEVEL_NUM=0 ;;  INFO) LOG_STDERR_LEVEL_NUM=1 ;;
        WARN)  LOG_STDERR_LEVEL_NUM=2 ;;  ERROR) LOG_STDERR_LEVEL_NUM=3 ;;
        FATAL) LOG_STDERR_LEVEL_NUM=4 ;;  *)     LOG_STDERR_LEVEL_NUM=1 ;;
    esac

    # ── Rotate old logs ──
    _log_rotate
}

# Date-based log rotation: rename stale files, purge >7 days old
_log_rotate() {
    [[ -z "$LOG_DIR" || ! -d "$LOG_DIR" ]] && return
    local _today _f _fdate
    _today=$(date '+%Y-%m-%d' 2>/dev/null || echo "")
    [[ -z "$_today" ]] && return
    for _f in "$LOG_DIR/bashagt.log" "$LOG_DIR/access.log"; do
        [[ -f "$_f" ]] || continue
        _fdate=$(date -r "$_f" '+%Y-%m-%d' 2>/dev/null || echo "$_today")
        if [[ "$_fdate" != "$_today" ]]; then
            mv "$_f" "${_f%.log}-$_fdate.log" 2>/dev/null || true
        fi
    done
    find "$LOG_DIR" -maxdepth 1 -name '*.log-*' -mtime +7 -delete 2>/dev/null || true
}

# ── Enhanced log() with buffered file output + level filtering ──
log() {
    local level="INFO" lvl_num=1 color="$GRAY"

    # Parse level prefix from first argument
    case "$1" in
        DEBUG:*) level="DEBUG"; lvl_num=0 ;;
        INFO:*)  level="INFO";  lvl_num=1 ;;
        WARN:*)  level="WARN";  lvl_num=2; color="$YELLOW" ;;
        ERROR:*) level="ERROR"; lvl_num=3; color="$RED" ;;
        FATAL:*) level="FATAL"; lvl_num=4; color="$RED" ;;
    esac

    # Strip level prefix to get clean message
    local msg="$*"
    msg="${msg#DEBUG: }"; msg="${msg#INFO: }"; msg="${msg#WARN: }"
    msg="${msg#ERROR: }"; msg="${msg#FATAL: }"

    # ── File output (buffered, DEBUG threshold) ──
    if (( lvl_num >= ${LOG_LEVEL_NUM:-0} )); then
        if [[ -n "${LOG_DIR:-}" && -d "$LOG_DIR" ]]; then
            local _ts; printf -v _ts '%(%Y-%m-%dT%H:%M:%S)T' -1 2>/dev/null || _ts=$(date '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || echo "????")
            local _src="${FUNCNAME[1]:-main}:${BASH_LINENO[0]:-0}"
            local _line; printf -v _line '%s [%-5s] [%s] %s' "$_ts" "$level" "$_src" "$msg"
            if (( BASH_SUBSHELL > 0 )); then
                printf '%s\n' "$_line" >> "$LOG_DIR/bashagt.log" 2>/dev/null || true
            else
                _LOG_BUF+=("$_line")
                if ((${#_LOG_BUF[@]} >= _LOG_BUF_MAX)); then
                    _log_flush
                fi
            fi
        fi
    fi

    # ── Stderr output (INFO threshold, immediate) ──
    if (( lvl_num >= ${LOG_STDERR_LEVEL_NUM:-1} )); then
        if [[ "${BASHAGT_LOG_STDERR:-0}" == "1" ]]; then
            printf "${color}[bashagt]${RESET} %s\n" "$msg" >&2
        fi
    fi
}

die() { printf '[bashagt] FATAL: %s\n' "$*" >&2; log "FATAL: $*"; exit 1; }

# ── Flask-style HTTP access logging ──
# Usage: access_log METHOD PATH STATUS LATENCY_MS [SESSION_ID] [BYTES]
access_log() {
    local _method="$1" _path="$2" _status="$3" _elapsed="$4"
    local _sid="${5:--}" _bytes="${6:--}"

    # ── Stderr: Flask-style colored output ──
    if [[ "${BASHAGT_LOG_STDERR:-0}" == "1" ]]; then
        local _color="$GREEN"
        (( _status >= 400 )) && _color="$YELLOW"
        (( _status >= 500 )) && _color="$RED"
        local _stext; _stext=$(_gw_status_text_for_log "$_status")
        local _display="${_path:0:50}"
        printf "${_color}%-6s %-50s${RESET} ${GRAY}->${RESET} ${_color}%3s %-7s${RESET} ${DIM}(%5sms)${RESET}\n" \
            "$_method" "$_display" "$_status" "$_stext" "$_elapsed" >&2
    fi

    # ── File: structured access log (buffered, flushed via _log_flush) ──
    if [[ -n "${LOG_DIR:-}" && -d "$LOG_DIR" ]]; then
        local ts; ts=$(date '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || echo "0000-00-00T00:00:00")
        local _entry; printf -v _entry '%s %-6s %s %3s %5sms %s %s\n' \
            "$ts" "$_method" "$_path" "$_status" "$_elapsed" "$_sid" "$_bytes"
        _ACCESS_BUF+=("$_entry")
    fi
}

# Helper: HTTP status code → human-readable text for log display
_gw_status_text_for_log() {
    case "$1" in
        200) printf 'OK' ;;      201) printf 'Created' ;;
        202) printf 'Accepted' ;; 204) printf 'NoCntnt' ;;
        301) printf 'Moved' ;;    304) printf 'NotMod' ;;
        400) printf 'BadReq' ;;   401) printf 'Unauth' ;;
        403) printf 'Forbdn' ;;   404) printf 'NotFnd' ;;
        405) printf 'MethBad' ;;  408) printf 'Timeout' ;;
        409) printf 'Conflct' ;;  429) printf 'RateLmt' ;;
        500) printf 'SrvrErr' ;;  502) printf 'BadGate' ;;
        503) printf 'Unavail' ;;  504) printf 'GateTim' ;;
        *)   printf '%s' "$1" ;;
    esac
}

# ── ERR trap: capture crash context and write to log before cleanup ──
_log_err_trap() {
    local _ec=$? _cmd="${BASH_COMMAND:-?}"
    local _lineno="${BASH_LINENO[0]}" _func="${FUNCNAME[1]:-main}"

    # Guard — prevent recursion; skip SIGPIPE and SIGINT
    (( _ERR_TRAP_GUARD )) && return
    _ERR_TRAP_GUARD=1
    [[ "$_ec" -eq 141 || "$_ec" -eq 130 ]] && { _ERR_TRAP_GUARD=0; return; }

    # Build stack trace (up to 8 frames, skip trap handler at index 0)
    local _stack="" _i
    for ((_i=1; _i < ${#FUNCNAME[@]} && _i < 9; _i++)); do
        _stack+="  at ${FUNCNAME[$_i]}:${BASH_LINENO[$_i-1]}\n"
    done

    local _ts; _ts=$(date '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || echo "0000-00-00T00:00:00")

    # File log with full context
    if [[ -n "${LOG_DIR:-}" && -d "$LOG_DIR" ]]; then
        {
            printf '%s [ERROR] [%s:%s] exit=%s cmd=%s\n' \
                "$_ts" "$_func" "$_lineno" "$_ec" "$_cmd"
            printf '%s [ERROR] [%s:%s] stack:\n%s' \
                "$_ts" "$_func" "$_lineno" "$_stack"
        } >> "$LOG_DIR/bashagt.log" 2>/dev/null || true
    fi

    # Stderr: brief one-line summary with command and stack
    printf "${RED}[bashagt]${RESET} ERROR: exit=%s cmd=%s at %s:%s\n" \
        "$_ec" "$_cmd" "$_func" "$_lineno" >&2
    local _stk_line="${FUNCNAME[1]}:${BASH_LINENO[0]}"
    local _si
    for ((_si=2; _si<${#FUNCNAME[@]}&&_si<6; _si++)); do
        _stk_line+=" ← ${FUNCNAME[$_si]}:${BASH_LINENO[$_si-1]}"
    done
    printf "${RED}[bashagt]${RESET}   stack: %s\n" "$_stk_line" >&2

    _ERR_TRAP_GUARD=0
}

_log_enable_err_trap()  { trap '_log_err_trap' ERR; }


# ── Color palette (single source of truth) ──
# Format: name|dark_sgr|light_sgr|category
# sgr = params between \033[ and m (e.g. "38;2;R;G;B" or "1" for bold)
_color_palette() {
    cat <<'PALETTE'
kw|38;2;86;156;214|38;2;0;0;255|syntax
str|38;2;206;145;120|38;2;163;21;21|syntax
cmt|38;2;106;153;85|38;2;0;128;0|syntax
fn|38;2;220;220;170|38;2;121;94;38|syntax
cls|38;2;78;201;176|38;2;38;127;127|syntax
var|38;2;156;220;254|38;2;0;16;128|syntax
num|38;2;181;206;168|38;2;9;134;88|syntax
dec|38;2;255;215;0|38;2;128;128;0|syntax
esc|38;2;215;186;125|38;2;128;80;0|syntax
ok|38;2;106;153;85|38;2;0;128;0|ui
err|38;2;244;71;71|38;2;163;21;21|ui
warn|38;2;220;220;170|38;2;121;94;38|ui
path|38;2;86;156;214|38;2;0;0;255|ui
cmd|38;2;220;220;170|38;2;121;94;38|ui
meta|38;2;128;128;128|38;2;100;100;100|ui
sel|7|7|ui
pink|38;5;218|38;5;168|accent
cyan_bold|1;36|38;2;0;120;120|accent
diff_add_bg|48;2;20;50;30|48;2;230;255;235|diff
diff_add_fg|38;2;87;210;120|38;2;40;167;69|diff
diff_del_bg|48;2;50;18;22|48;2;255;235;238|diff
diff_del_fg|38;2;245;100;110|38;2;215;58;73|diff
flash_bg|48;2;20;25;55|48;2;235;240;255|flash
flash_fg|38;2;100;170;255|38;2;40;70;180|flash
flash_safe_bg|48;2;60;40;15|48;2;255;245;225|flash
flash_safe_fg|38;2;255;200;80|38;2;180;130;30|flash
prompt_input|1;36|38;2;0;120;120|prompt
prompt_safe_on|1;93|38;2;200;160;0|prompt
banner_logo_L1|1;36|38;2;0;140;140|banner
banner_logo_L2|1;36|38;2;0;130;140|banner
banner_logo_L3|1;36|38;2;0;120;145|banner
banner_logo_L4|1;36|38;2;0;110;150|banner
banner_logo_L5|1;36|38;2;0;100;150|banner
banner_logo_L6|1;36|38;2;0;90;145|banner
banner_label|90|38;2;100;100;100|banner
bold|1|1|chrome
dim|2|2|chrome
reset|0|0|chrome
PALETTE
}

# Assemble a raw SGR param string into a full ANSI escape sequence.
# Uses printf -v to avoid subshell overhead.
_color_assemble() {
    local _outvar="$1" _params="$2"
    [[ -z "$_params" || "$_params" == "-" ]] && { printf -v "$_outvar" ''; return; }
    printf -v "$_outvar" '\033[%sm' "$_params"
}

# Look up a color by name, with lazy-cached palette resolution.
# First call fills the full cache; subsequent calls are O(1) reads.
declare -A _CLR_CACHE
_color_get() {
    local _name="$1"
    [[ -z "${_CLR_CACHE[$_name]:-}" ]] && {
        local _n _dark _light _cat _mode_dark
        [[ "${BASHAGT_DARK_MODE:-true}" != "false" && "${BASHAGT_DARK_MODE:-true}" != "0" ]] && _mode_dark=1 || _mode_dark=0
        while IFS='|' read -r _n _dark _light _cat; do
            [[ -z "$_n" || "$_n" == '#'* ]] && continue
            if (( _mode_dark )); then
                printf -v '_CLR_CACHE[$_n]' '\033[%sm' "$_dark"
            else
                printf -v '_CLR_CACHE[$_n]' '\033[%sm' "$_light"
            fi
        done < <(_color_palette)
    }
    printf '%s' "${_CLR_CACHE[$_name]:-}"
}

# ── Color palette resolver (VS Code Dark+ / Light+) ──
# Called at init and on dark_mode toggle. Sets all semantic color variables.
# Also sets legacy aliases for backward compat (GREEN, CYAN, YELLOW, RED, GRAY,
# LIGHT_GREEN, LIGHT_YELLOW, INVERT, LIGHT_PINK, LIGHT_CYAN).
_colors_resolve() {
    _CLR_CACHE=()
    local _name _dark _light _cat _code _mode_dark
    [[ "${BASHAGT_DARK_MODE:-true}" != "false" && "${BASHAGT_DARK_MODE:-true}" != "0" ]] && _mode_dark=1 || _mode_dark=0

    while IFS='|' read -r _name _dark _light _cat; do
        [[ -z "$_name" || "$_name" == '#'* ]] && continue
        if (( _mode_dark )); then
            printf -v _code '\033[%sm' "$_dark"
        else
            printf -v _code '\033[%sm' "$_light"
        fi
        _CLR_CACHE[$_name]="$_code"
        case "$_cat" in
            syntax) printf -v "${_name^^}"           '%s' "$_code" ;;
            ui)     printf -v "${_name^^}_COLOR"     '%s' "$_code" ;;
            diff|flash|prompt|banner|chrome|accent)
                    printf -v "$(tr 'a-z' 'A-Z' <<< "$_name")" '%s' "$_code" ;;
        esac
    done < <(_color_palette)

    # Legacy aliases (backward compatible)
    GREEN=$OK_COLOR; CYAN=$PATH_COLOR; YELLOW=$CMD_COLOR; RED=$ERR_COLOR
    GRAY=$META_COLOR; INVERT=$SEL_COLOR
    LIGHT_GREEN=$NUM; LIGHT_YELLOW=$DEC
    LIGHT_PINK=$PINK; LIGHT_CYAN=$CYAN_BOLD

    _bsrp_assemble
}

# Assemble BSRP body by injecting the active mode's color table + examples.
# Called by _colors_resolve() whenever dark/light mode changes.
_bsrp_assemble() {
    [[ -z "${BSRP_TEMPLATE:-}" ]] && return 0
    local _table _examples

    printf -v _table \
        '  kw  = %s    #569CD6  blue    if/for/def/class/return\n' "$KW"
    _table+=$(printf '  str = %s   #CE9178  orange  "hello"'"'"'world'"'"'\n' "$STR")
    _table+=$(printf '  cmt = %s    #6A9955  green   # comment // comment\n' "$CMT")
    _table+=$(printf '  fn  = %s   #DCDCAA  yellow  greet() grep echo\n' "$FN")
    _table+=$(printf '  cls = %s    #4EC9B0  teal    MyClass ValueError\n' "$CLS")
    _table+=$(printf '  var = %s   #9CDCFE  lt-blue name $HOME ${VAR}\n' "$VAR")
    _table+=$(printf '  num = %s   #B5CEA8  lt-grn  42 3.14 0xFF\n' "$NUM")
    _table+=$(printf '  dec = %s     #FFD700  gold    @decorator\n' "$DEC")
    _table+=$(printf '  esc = %s   #D7BA7D  brown   \\n \\t \\\\ (in strings)\n' "$ESC")
    _table+=$(printf '  rst = %s                                   reset all' "$RESET")

    printf -v _examples 'Python example:\n<code>\n'
    _examples+=$(printf '%sdef%s %sgreet%s(%sname%s):\n' \
        "$KW" "$RESET" "$FN" "$RESET" "$VAR" "$RESET")
    _examples+=$(printf '    %s"""Say hello."""%s\n' "$CMT" "$RESET")
    _examples+=$(printf '    %sreturn%s %sf"Hello, {%s%sname%s%s}!"%s\n' \
        "$KW" "$RESET" "$STR" "$RESET" "$VAR" "$RESET" "$STR" "$RESET")
    _examples+='</code>

Shell example:
<code>
'
    _examples+=$(printf '%sfor%s %si%s %sin%s {%s1%s..%s3%s};\n' \
        "$KW" "$RESET" "$VAR" "$RESET" "$KW" "$RESET" "$NUM" "$RESET" "$NUM" "$RESET")
    _examples+=$(printf '%sdo%s\n' "$KW" "$RESET")
    _examples+=$(printf '    %secho%s %s"$i: %s%s$RANDOM%s%s"%s\n' \
        "$FN" "$RESET" "$STR" "$RESET" "$VAR" "$RESET" "$STR" "$RESET")
    _examples+=$(printf '    %ssleep%s %s1%s\n' "$FN" "$RESET" "$NUM" "$RESET")
    _examples+=$(printf '%sdone%s\n</code>' "$KW" "$RESET")

    local _section; printf -v _section '%s\n\n%s\n\n%s\n\n%s' \
        'COLOR CONSTANTS (current mode):' \
        "$_table" \
        'HIGHLIGHTING PRIORITY (H2): comments > strings > keywords > class names > builtins > function calls > decorators > variables > numbers' \
        "§CODE EXAMPLES:$_examples"
    FORMAT_AGENT_BODY="${BSRP_TEMPLATE//__COLOR_SECTION__/$_section}"
    AGENTS[format]="$FORMAT_AGENT_BODY"
}

# Initialize colors (may be re-called on dark_mode toggle)
_colors_resolve

# Spinner frames for thinking indicator
SPINNER=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
SPINNER_IDX=0
SPINNER_LEN=${#SPINNER[@]}

# --- Dynamic status line system ---
# Status lines persist as visual history — "done" replaces spinner, line stays.
# Protocol: begin(\n+content) → update(\r overwrite) → done(\r+"done",\n close).
# Tools use info() for static one-line records.
STATUS_ACTIVE=0
_BYPASS_FD=1  # stream emit bypass fd — set to 8 when FIFO is active
_PERSISTENT_FIFO=""           # persistent renderer FIFO path (created once at init)
_PERSISTENT_RENDERER_PID=""   # persistent renderer background PID
_PERSISTENT_RESP_FIFO=""      # response FIFO: renderer writes "ok" on done → main syncs

# ── Unified Process Registry ──
declare -A _PROC             # pid → "cat|ts|desc"
_PROCS_FILE=""               # /tmp/bashagt_procs.XXXXXX (created by _proc_init)


# ── Slash command registry ──
# Adding a new slash command: (1) define a _slash_<name>() handler, (2) call
# _register_slash. Completion list is auto-generated from registered commands.
declare -A SLASH_COMMANDS

_register_slash() {
    local _cmd="$1" _handler="$2"
    SLASH_COMMANDS["$_cmd"]="$_handler"
}

_slash_dispatch() {
    local _trimmed="$1" _cmd _handler
    _cmd="${_trimmed%% *}"
    _cmd="${_cmd#/}"
    _handler="${SLASH_COMMANDS[$_cmd]:-}"
    [[ -n "$_handler" ]] || return 1
    _SLASH_FALLTHROUGH=0
    log "DEBUG: SLASH       cmd=$_cmd"
    "$_handler" "$_trimmed"
}

# ── Portable utility functions (work on Linux/GNU and macOS/BSD) ──

# Portable millisecond timestamp
# bash 5.0+ uses $EPOCHREALTIME builtin (zero fork, microsecond precision)
# Falls back to date(1) for bash 4.x / macOS BSD date
_timestamp_ms() {
    if [[ -n "${EPOCHREALTIME:-}" ]]; then
        local _ts="${EPOCHREALTIME/.}"
        printf '%s\n' "${_ts:0:13}"
        return 0
    fi
    local _ts
    _ts=$(date +%s%3N 2>/dev/null)
    # Validate: BSD date outputs literal chars (e.g. "17788129213N") for unknown specifiers
    [[ "$_ts" =~ ^[0-9]+$ ]] && { printf '%s\n' "$_ts"; return 0; }
    # Fallback: second precision + append "000" for millisecond format
    printf '%s000\n' "$(date +%s)"
}

# Fork-free variant: writes 13-digit ms timestamp to named variable via printf -v
_ts_to() {
    if [[ -n "${EPOCHREALTIME:-}" ]]; then
        local _t="${EPOCHREALTIME/.}"
        printf -v "$1" '%s' "${_t:0:13}"
    else
        printf -v "$1" '%s000' "$(date +%s)"
    fi
}

# Fork-free: writes current ms timestamp to global _NOW_MS
_now_ms() {
    if [[ -n "${EPOCHREALTIME:-}" ]]; then
        local _t="${EPOCHREALTIME/.}"
        printf -v _NOW_MS '%s' "${_t:0:13}"
    else
        printf -v _NOW_MS '%s000' "$(date +%s)"
    fi
}

# Portable port-in-use check. Tries ALL available tools — not just the first —
# because some tools exist but are broken (e.g. fuser on WSL). Returns 0 if busy.
_port_is_busy() {
    local _port="$1"
    if command -v fuser >/dev/null 2>&1; then
        fuser "$_port/tcp" >/dev/null 2>&1 && return 0
    fi
    if command -v lsof >/dev/null 2>&1; then
        lsof -i "tcp:$_port" -sTCP:LISTEN >/dev/null 2>&1 && return 0
    fi
    if command -v ss >/dev/null 2>&1; then
        ss -tlnp "sport = :$_port" 2>/dev/null | grep -q ":$_port" && return 0
    fi
    return 1
}

# Portable port-kill. Tries ALL available tools to ensure the port is freed.
_port_kill() {
    local _port="$1"
    if command -v fuser >/dev/null 2>&1; then
        fuser -k "$_port/tcp" 2>/dev/null || true
    fi
    if command -v lsof >/dev/null 2>&1; then
        local _pid
        _pid=$(lsof -ti "tcp:$_port" -sTCP:LISTEN 2>/dev/null || true)
        [[ -n "$_pid" ]] && kill "$_pid" 2>/dev/null || true
    fi
}

# Portable lock using mkdir (atomic on all Unix, no external deps)
_lock_acquire() {
    local _lockdir="$1.lock"
    while ! mkdir "$_lockdir" 2>/dev/null; do
        sleep 0.05
    done
}

_lock_acquire_nb() {
    local _lockdir="$1.lock"
    mkdir "$_lockdir" 2>/dev/null
}

_lock_release() {
    local _lockdir="$1.lock"
    rmdir "$_lockdir" 2>/dev/null || true
}

# Kill children of a given PPID. Uses pkill -P on Linux, ps+awk fallback on
# macOS/BSD where pkill does not support -P.
_pkill_children() {
    local _ppid="$1" _sig="${2:-TERM}"
    pkill -"$_sig" -P "$_ppid" 2>/dev/null && return 0
    local _pids
    _pids=$(ps -eo pid= -o ppid= 2>/dev/null | awk -v ppid="$_ppid" '$2 == ppid {print $1}')
    [[ -n "$_pids" ]] && kill -"$_sig" $_pids 2>/dev/null || true
    return 0
}

# Kill entire process tree recursively, bottom-up (children before parent).
# Unlike _pkill_children (direct children only), this traverses the full
# descendant tree. Depth-first: leaves are killed first so no process is
# reparented to init before its children are killed.
_pkill_tree() {
    local _ppid="$1" _sig="${2:-TERM}"
    local _pids _child
    _pids=$(ps -eo pid= -o ppid= 2>/dev/null | awk -v ppid="$_ppid" '$2 == ppid {print $1}')
    for _child in $_pids; do
        _pkill_tree "$_child" "$_sig"
    done
    kill -"$_sig" "$_ppid" 2>/dev/null || true
}

# Portable pgrep: prefers pgrep, falls back to ps+awk (Termux/minimal systems).
_pgrep_safe() {
    local _pattern="$1"
    if command -v pgrep >/dev/null 2>&1; then
        pgrep -f "$_pattern" 2>/dev/null
    elif command -v ps >/dev/null 2>&1; then
        ps -eo pid= -o args= 2>/dev/null | grep -i "$_pattern" | grep -v grep | awk '{print $1}'
    fi
}

# Detect Termux/Android environment. Returns 0 if running in Termux.
_detect_termux() {
    [[ -n "${PREFIX:-}" && -d "${PREFIX}/etc" ]] && return 0
    [[ "$OSTYPE" == "linux-android" ]] && return 0
    return 1
}

# ============================================================================
# SECTION 2d: Unified Process Registry & Lifecycle Management
# ============================================================================
# Every background process is registered in a file-backed registry.
# Categories define ordered shutdown: timer → hook → trace → tool → agent
# → api → mcp → worker → renderer.

# Initialize the process registry file.
_proc_init() {
    _PROCS_FILE=$(_mktemp_u /tmp/bashagt_procs.XXXXXX) || { _PROCS_FILE=""; return 1; }
    : > "$_PROCS_FILE"
    return 0
}

# Register an already-spawned background process.
# Usage: _proc_register <pid> <category> <desc>
_proc_register() {
    local _pid="$1" _cat="$2" _desc="${3:-}"
    [[ -z "$_pid" || "$_pid" == "0" ]] && return 1
    local _ts; _ts=$(_timestamp_ms)
    _PROC["$_pid"]="$_cat|$_ts|$_desc"
    [[ -n "$_PROCS_FILE" ]] && printf '%s\t%s\t%s\t%s\n' "$_pid" "$_cat" "$_ts" "$_desc" >> "$_PROCS_FILE"
    return 0
}

# Unregister a process from the registry.
_proc_unregister() {
    local _pid="$1"
    unset '_PROC[$_pid]' 2>/dev/null || true
}

# Kill a registered process tree. Falls back from _pkill_tree to kill on failure.
_proc_kill() {
    local _pid="$1" _sig="${2:-TERM}"
    [[ -z "$_pid" || "$_pid" == "0" ]] && return 1
    _pkill_tree "$_pid" "$_sig" 2>/dev/null || kill -"$_sig" "$_pid" 2>/dev/null || true
    _proc_unregister "$_pid"
}

# Kill all processes in a category.
_proc_kill_cat() {
    local _cat="$1" _sig="${2:-TERM}" _pid _entry
    _proc_reap
    for _pid in "${!_PROC[@]}"; do
        _entry="${_PROC[$_pid]}"
        [[ "${_entry%%|*}" == "$_cat" ]] || continue
        _pkill_tree "$_pid" "$_sig" 2>/dev/null || kill -"$_sig" "$_pid" 2>/dev/null || true
        unset '_PROC[$_pid]'
    done
}

# Wait for a specific PID with optional timeout (ms). Returns 0 if exited, 1 if timeout.
_proc_wait() {
    local _pid="$1" _timeout="${2:-0}" _n=0
    if (( _timeout > 0 )); then
        while kill -0 "$_pid" 2>/dev/null && (( _n * 50 < _timeout )); do
            sleep 0.05; _n=$((_n + 1))
        done
        kill -0 "$_pid" 2>/dev/null && return 1
    else
        wait "$_pid" 2>/dev/null || true
    fi
    return 0
}

# Sync registry: re-read file, remove dead PIDs, rewrite atomically.
_proc_reap() {
    [[ -n "$_PROCS_FILE" && -f "$_PROCS_FILE" ]] || return 0
    local _line _pid _cat _ts _desc _new="" _live=0
    while IFS=$'\t' read -r _pid _cat _ts _desc; do
        [[ -z "$_pid" ]] && continue
        if kill -0 "$_pid" 2>/dev/null; then
            _PROC["$_pid"]="$_cat|$_ts|$_desc"
            printf -v _new '%s%s\t%s\t%s\t%s\n' "$_new" "$_pid" "$_cat" "$_ts" "$_desc"
            _live=$((_live + 1))
        else
            unset '_PROC[$_pid]' 2>/dev/null || true
        fi
    done < "$_PROCS_FILE"
    printf '%s' "$_new" > "${_PROCS_FILE}.tmp" && mv "${_PROCS_FILE}.tmp" "$_PROCS_FILE"
    return 0
}

# List all registered processes (debug).
_proc_list() {
    _proc_reap
    local _pid _cat _ts _desc
    printf '%-8s %-12s %s\n' "PID" "CAT" "DESC"
    for _pid in "${!_PROC[@]}"; do
        IFS='|' read -r _cat _ts _desc <<< "${_PROC[$_pid]}"
        printf '%-8s %-12s %s\n' "$_pid" "$_cat" "$_desc"
    done
}

# Ordered shutdown: kill all registered processes by category priority.
# Categories: timer (0) → hook (1) → trace (2) → tool (3) → agent (4)
# → api (5) → mcp (6) → worker (7) → renderer (8)
_proc_shutdown() {
    local _order=(timer hook trace tool agent api mcp worker renderer) _cat _pid
    _proc_reap
    for _cat in "${_order[@]}"; do
        for _pid in "${!_PROC[@]}"; do
            [[ "${_PROC[$_pid]%%|*}" == "$_cat" ]] || continue
            _pkill_tree "$_pid" TERM 2>/dev/null || true
            unset '_PROC[$_pid]'
        done
    done
    # Reap + force-kill stragglers
    _proc_reap
    for _pid in "${!_PROC[@]}"; do
        _pkill_tree "$_pid" KILL 2>/dev/null || true
        unset '_PROC[$_pid]'
    done
    : > "$_PROCS_FILE"
    return 0
}

# Functional nc listen-flag probe. Tries nc -l -p PORT (Linux-OpenBSD/ncat),
# then nc -l PORT (macOS BSD). Sets global NC_LISTEN array, returns 0 on success.
_nc_detect_listen() {
    local _port=59655 _pid=0 _ok=0
    # ncat (nmap) always uses -l -p like Linux-OpenBSD
    if command -v ncat >/dev/null 2>&1; then
        NC_LISTEN=(ncat -l -p); return 0
    fi
    # Probe: Linux-OpenBSD pattern (nc -l -p PORT)
    nc -l -p $_port -w 1 &>/dev/null & _pid=$!
    sleep 0.05
    if kill -0 $_pid 2>/dev/null; then _ok=1; NC_LISTEN=(nc -l -p); fi
    kill $_pid 2>/dev/null; wait $_pid 2>/dev/null
    (( _ok )) && return 0
    # Probe: macOS BSD pattern (nc -l PORT)
    nc -l $_port -w 1 &>/dev/null & _pid=$!
    sleep 0.05
    if kill -0 $_pid 2>/dev/null; then _ok=1; NC_LISTEN=(nc -l); fi
    kill $_pid 2>/dev/null; wait $_pid 2>/dev/null
    (( _ok )) && return 0
    return 1
}

# Find the best user-writable bin directory in PATH.  Cross-platform:
# prefers XDG ~/.local/bin on Linux, /usr/local/bin on Homebrew macOS,
# falls back to scanning PATH for the first writable dir, finally
# creating ~/.local/bin if nothing else works.
# Used by --install / --update / --uninstall for symlink placement.
_find_user_bin() {
    # Termux/Android: $PREFIX/bin is the canonical user-writable bin directory
    if [[ -n "${PREFIX:-}" && -d "$PREFIX/bin" && -w "$PREFIX/bin" ]] \
       && [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then
        printf '%s' "$PREFIX/bin"; return
    fi
    # XDG standard — Linux default, in PATH via ~/.profile on Ubuntu
    if [[ -d "$HOME/.local/bin" && -w "$HOME/.local/bin" ]] \
       && [[ ":$PATH:" == *":$HOME/.local/bin:"* ]]; then
        printf '%s' "$HOME/.local/bin"; return
    fi
    # macOS Homebrew makes /usr/local/bin user-writable
    if [[ -d /usr/local/bin && -w /usr/local/bin ]]; then
        printf '%s' /usr/local/bin; return
    fi
    # Traditional Unix
    if [[ -d "$HOME/bin" && -w "$HOME/bin" ]] \
       && [[ ":$PATH:" == *":$HOME/bin:"* ]]; then
        printf '%s' "$HOME/bin"; return
    fi
    # Scan PATH for the first writable directory
    local _d
    local IFS=':'; set -- $PATH
    for _d; do
        [[ -d "$_d" && -w "$_d" ]] && { printf '%s' "$_d"; return; }
    done
    # Nothing found — create the XDG standard directory
    mkdir -p "$HOME/.local/bin" 2>/dev/null || true
    printf '%s' "$HOME/.local/bin"
}

# Ensure a directory is on PATH, persisting the addition to .bashrc and .zshrc.
_ensure_path_entry() {
    local _dir="$1"
    [[ ":$PATH:" == *":$_dir:"* ]] && return
    local _line="export PATH=\"$_dir:\$PATH\"  # bashagt"
    local _rc
    for _rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
        [[ -f "$_rc" ]] || continue
        grep -qF "$_dir" "$_rc" 2>/dev/null && continue
        printf '\n%s\n' "$_line" >> "$_rc"
    done
    export PATH="$_dir:$PATH"
}

# Create / refresh the bashagt symlink in the best user-bin directory.
_install_symlink() {
    local _bin_dir; _bin_dir=$(_find_user_bin)
    _ensure_path_entry "$_bin_dir"
    local _target="$HOME/.bashagt/bashagt"
    local _link="$_bin_dir/bashagt"
    # Remove stale symlink
    [[ -L "$_link" ]] && rm -f "$_link"
    # Refuse to overwrite a non-symlink file
    if [[ -e "$_link" ]]; then
        printf '[bashagt] WARNING: %s exists and is not a symlink — skipping\n' "$_link" >&2
        return 1
    fi
    ln -sf "$_target" "$_link" 2>/dev/null || true
}

# ── Latency tracing: lightweight timing checkpoints ──
# Set BASHAGT_TIMING=0 to disable. Writes to $LOG_DIR/timing.log (or /tmp fallback).
_tm() {
    [[ "${BASHAGT_TIMING:-1}" != "0" ]] || return 0
    [[ -z "${_TM_LOG:-}" ]] && _TM_LOG="${LOG_DIR:-/tmp}/timing.log"
    local _n; _n=$(_timestamp_ms)
    printf 'TIMING: %s ms=%s\n' "$1" "$_n" >> "$_TM_LOG"
}

# Portable file mtime (epoch seconds). GNU: stat -c %Y, BSD: stat -f %m
_file_mtime() {
    stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0
}

# Portable epoch-to-formatted-date (GNU uses -d @, BSD uses -r)
_date_from_epoch() {
    local _epoch="$1" _fmt="${2:-+%Y-%m-%d %H:%M}"
    date -d "@$_epoch" "$_fmt" 2>/dev/null || date -r "$_epoch" "$_fmt" 2>/dev/null || echo "$_epoch"
}

# Portable mktemp wrappers. Use $TMPDIR when available (Termux), BSD fallback.
# All temp files in /tmp are transparently relocated to $TMPDIR when set.
_mktemp_file() {
    local _t="${1:-${BASHAGT_TMPDIR:-/tmp}/bashagt.XXXXXX}"
    [[ -n "${TMPDIR:-}" && "$_t" == /tmp/* ]] && _t="${TMPDIR}${_t#/tmp}"
    mktemp "$_t" 2>/dev/null || mktemp -t "${_t##*/}" 2>/dev/null
}
_mktemp_dir() {
    local _t="${1:-${BASHAGT_TMPDIR:-/tmp}/bashagt.XXXXXX}"
    [[ -n "${TMPDIR:-}" && "$_t" == /tmp/* ]] && _t="${TMPDIR}${_t#/tmp}"
    mktemp -d "$_t" 2>/dev/null || mktemp -d -t "${_t##*/}" 2>/dev/null
}
_mktemp_u() {  # dry-run (FIFO names)
    local _t="${1:-${BASHAGT_TMPDIR:-/tmp}/bashagt.XXXXXX}"
    [[ -n "${TMPDIR:-}" && "$_t" == /tmp/* ]] && _t="${TMPDIR}${_t#/tmp}"
    mktemp -u "$_t" 2>/dev/null || {
        # Fallback: busybox mktemp doesn't support -u
        local _r; printf -v _r '%s_%s_%s' "${_t%XXXXXX}" "$$" "$RANDOM"
        printf '%s\n' "$_r"
    }
}


# ── JSON stream protocol emitter (activated by --stream flag) ──
# Writes one JSON object per line (JSONL) to stdout. Each frame has
# at minimum {"type":"..."}. Payload is a pre-built jq JSON string.
# Always active — kernel emits JSONL for every output.
_stream_emit() {
    local _type="$1" _payload="$2"
    local _ts; _ts_to _ts
    if [[ "$_payload" == '{}' ]]; then
        printf '{"type":"%s","ts":%s}\n' "$_type" "$_ts"
    else
        printf '{"type":"%s","ts":%s,%s\n' "$_type" "$_ts" "${_payload:1}"
    fi
}

# ── JSON stream helper: key-value variant ──
# Like _stream_emit but accepts key-value pairs instead of pre-built payload.
# Builds {"type":$type,"ts":$ts,"key1":"val1",...} entirely via printf (zero jq fork).
# Values must NOT contain JSON special chars — caller guarantees safety.
_stream_kv() {
    local _type="$1" _ts; _ts_to _ts; shift
    printf '{"type":"%s","ts":%s' "$_type" "$_ts"
    while (($# >= 2)); do
        printf ',"%s":"%s"' "$1" "$2"; shift 2
    done
    printf '}\n'
}

# Fork-free text emitter: {"type":"text","ts":...,"content":"..."}
# Handles JSON string escaping (\, ", LF, CR, TAB, ESC) in pure bash.
_stream_text() {
    local _content="$1" _ts; _ts_to _ts
    local _esc="${_content//\\/\\\\}"      # \ → \\ (FIRST)
    _esc="${_esc//\"/\\\"}"                 # " → \"
    _esc="${_esc//$'\n'/\\n}"               # LF → \n
    _esc="${_esc//$'\r'/\\r}"               # CR → \r
    _esc="${_esc//$'\t'/\\t}"               # TAB → \t
    _esc="${_esc//$'\033'/\\u001b}"         # ESC →  (ANSI codes)
    printf '{"type":"text","ts":%s,"content":"%s"}\n' "$_ts" "$_esc"
}

# ── UI Primitives (pure functions, return strings, no stderr) ──

# Leaf: Spinner — current braille frame
ui_spinner() { printf '%s' "${SPINNER[SPINNER_IDX]}"; }

# Leaf: Dot — colored ● based on state
# States: "flash"=flashing green/white, "white"=plain, "done"=green, "error"=red, "running"=spinner
ui_dot() {
    local _state="${1:-white}"
    case "$_state" in
        flash)  if (( ${_UI[dot_phase]:-0} == 0 )); then printf '%s●%s' "$LIGHT_GREEN" "$RESET"
                else printf '●'; fi ;;
        done)   printf '%s●%s' "$LIGHT_GREEN" "$RESET" ;;
        error)  printf '%s●%s' "$RED" "$RESET" ;;
        white)  printf '●' ;;
        *)      printf '●' ;;
    esac
}

# Leaf: Flag — colored ⚑ based on state (step header icon)
# States: "flash"=flashing green/white, "white"=plain, "done"=green, "error"=red

# Leaf: Time — formatted elapsed string
ui_time() { elapsed_fmt "${1:-0}"; }

# Fork-free variant: writes formatted time string to _UI_TIME via printf -v
_ui_time() {
    local ms="${1:-0}"
    local sec=$((ms/1000))
    if (( sec < 60 )); then
        local tenths=$(((ms%1000)/100))
        printf -v _UI_TIME '%d.%ds' "$sec" "$tenths"
    else
        local min=$((sec/60)); sec=$((sec%60))
        printf -v _UI_TIME '%dm%ds' "$min" "$sec"
    fi
}

# Leaf: Tokens — formatted "↑in ↓out" string (empty if both zero)
ui_tokens() {
    local _in="${1:-0}" _out="${2:-0}"
    printf '%s↑%s ↓%s%s' "$GRAY" "$(token_fmt "$_in")" "$(token_fmt "$_out")" "$RESET"
}

# Leaf: Label — styled text (style: bold, dim, green, red, cyan, yellow, gray, plain)
ui_label() {
    local _text="$1" _style="${2:-plain}"
    case "$_style" in
        bold)   printf '%s%s%s' "$BOLD" "$_text" "$RESET" ;;
        dim)    printf '%s%s%s' "$DIM" "$_text" "$RESET" ;;
        green)  printf '%s%s%s' "$GREEN" "$_text" "$RESET" ;;
        red)    printf '%s%s%s' "$RED" "$_text" "$RESET" ;;
        cyan)   printf '%s%s%s' "$CYAN" "$_text" "$RESET" ;;
        yellow) printf '%s%s%s' "$YELLOW" "$_text" "$RESET" ;;
        gray)   printf '%s%s%s' "$GRAY" "$_text" "$RESET" ;;
        pink)   printf '%s%s%s' "$LIGHT_PINK" "$_text" "$RESET" ;;
        *)      printf '%s' "$_text" ;;
    esac
}

# Leaf: ProgressBar — "████░░░░  3/12 (25%)"
ui_progress() {
    local _done="$1" _total="$2" _width="${3:-20}" _filled _empty _bar="" _i
    if (( _total == 0 )); then _filled=0
    else _filled=$(( _done * _width / _total )); fi
    _empty=$(( _width - _filled ))
    for ((_i=0; _i<_filled; _i++)); do _bar+="${GREEN}█${RESET}"; done
    for ((_i=0; _i<_empty; _i++)); do _bar+="${DIM}░${RESET}"; done
    printf '%s  %s%d/%d%s  (%s%d%%%s)' \
        "$_bar" "$BOLD" "$_done" "$_total" "$RESET" "$DIM" \
        "$(( _total > 0 ? _done * 100 / _total : 0 ))" "$RESET"
}

# ── Unified Display Context ──
# Single associative array for all animated display state.
# Replaces scattered globals: STATUS_ACTIVE, SPINNER_IDX, _DOT_PHASE, _DOT_FRAME,
# _LIVE_TREE_ACTIVE, _LIVE_TREE_LINES, _LIVE_TREE_ELAPSED_MS, etc.
declare -A _UI=(
    [mode]="off"
    [height]=0
    [spinner_idx]=0
    [dot_phase]=0
    [tick_count]=0
    [start_ts]=0
    [elapsed_ms]=0
)

# Unified animation tick: advance spinner, toggle dot, update elapsed.
# Call from: SSE event loop, async_spin poll loop, tool execution loop.

# ── JSONL → ANSI renderer (shared by interactive stream mode and OE layer) ──
# Parses one JSONL frame and renders ANSI to stdout.
# Returns 1 for "done" frame (signal to stop reading), 0 for all others.
_stream_render() {
    local _line="$1"
    _TOOL_NAME="${_TOOL_NAME:-}" ; _TOOL_DESC="${_TOOL_DESC:-}"

    # Passthrough non-JSON lines (legacy, error messages)
    [[ "$_line" == "{"* ]] || { printf '%s\n' "$_line"; return 0; }

    # Extract type first (always single-line, safe for read)
    local _type
    _type="${_line#\{\"type\":\"}"
    _type="${_type%%\"*}"
    [[ -z "$_type" ]] && { printf '%s\n' "$_line"; return 0; }

    case "$_type" in
        done) printf 'ok\n' >&6 2>/dev/null || true; return 1 ;;
        text)
            local _content; _content=$(jq -r '.content // ""' <<< "$_line" 2>/dev/null)
            _content="  ${_content//$'\n'/$'\n'  }"
            printf '%s\n' "$_content" ;;
        thinking)
            local _content; _content=$(jq -r '.content // ""' <<< "$_line" 2>/dev/null)
            printf '%s\n' "$_content" ;;
        warning)
            local _content; _content=$(jq -r '.content // ""' <<< "$_line" 2>/dev/null)
            printf '  %s%s%s\n' "$YELLOW" "$_content" "$RESET" ;;
        error)
            local _content; _content=$(jq -r '.content // ""' <<< "$_line" 2>/dev/null)
            printf '  %s%s%s\n' "$RED" "$_content" "$RESET" ;;
        info)
            local _content; _content=$(jq -r '.content // ""' <<< "$_line" 2>/dev/null)
            printf '  %s%s%s\n' "$GRAY" "$_content" "$RESET" ;;
        status_begin|status_update|status_done)
            # Refresh TERM_WIDTH on each status_begin (persistent renderer spans many turns)
            [[ "$_type" == "status_begin" ]] && TERM_WIDTH=$(detect_term_width)
            local _fields _icon _label _ela _itok _otok _desc _itok_fmt _otok_fmt _k
            _fields=$(jq -r '[.icon//"",.label//"",.elapsed_str//"",(.tokens_in//0|tostring),(.tokens_out//0|tostring),.desc//""]|join("'$'\036''")' <<< "$_line" 2>/dev/null)
            IFS=$'\036' read -r _icon _label _ela _itok _otok _desc <<< "$_fields"
            if [[ "$_type" == "status_done" ]]; then
                log "DEBUG: [RENDER] status_done label=[$_label] timer_label=[${_R_SPIN_LABEL:-}] timer_pid=[${_R_SPIN_TIMER_PID:-}]"
                # Stop self-animation timer
                if [[ -n "${_R_SPIN_TIMER_PID:-}" ]]; then
                    kill -USR1 "$_R_SPIN_TIMER_PID" 2>/dev/null || true
                    wait "$_R_SPIN_TIMER_PID" 2>/dev/null || true
                fi
                _R_SPIN_TIMER_PID=""
                rm -f "${_R_SPIN_DESC_FILE:-}" 2>/dev/null
                _R_SPIN_DESC_FILE=""
                local _ts=""
                if [[ "${_itok:-0}" -gt 0 || "${_otok:-0}" -gt 0 ]]; then
                    _itok_fmt="$_itok"; _otok_fmt="$_otok"
                    if (( _itok >= 1000 )); then _k=$(( _itok / 100 )); _itok_fmt="$(( _k / 10 )).$(( _k % 10 ))k"; fi
                    if (( _otok >= 1000 )); then _k=$(( _otok / 100 )); _otok_fmt="$(( _k / 10 )).$(( _k % 10 ))k"; fi
                    _ts=" ↑${_itok_fmt} ↓${_otok_fmt}"
                fi
                printf '\r  %s%s (%s)%s' "$GRAY" "$_label" "$_ela" "$_ts"
                [[ -n "$_desc" ]] && printf ' %s·%s %s' "$LIGHT_YELLOW" "$RESET$GRAY" "$_desc"
                printf '%s\033[K\n' "$RESET"
            else
                # Start self-animation timer on first status_begin
                if [[ "$_type" == "status_begin" && -z "${_R_SPIN_TIMER_PID:-}" ]]; then
                    log "DEBUG: [RENDER] status_begin START timer label=[$_label]"
                    _R_SPIN_LABEL="$_label"
                    _R_SPIN_ICON="${_icon:-●}"
                    _R_SPIN_DESC="${_desc:-}"
                    _R_SPIN_START_TS=$(_timestamp_ms)
                    # Timer runs in bg subshell → can't see desc updates. Use a
                    # temp file so the timer re-reads the latest desc each frame.
                    _R_SPIN_DESC_FILE="$(_mktemp_file /tmp/bashagt_spin_desc.XXXXXX)"
                    printf '%s' "${_R_SPIN_DESC:-}" > "$_R_SPIN_DESC_FILE" 2>/dev/null
                    if [[ "${_icon:-}" == *"●"* ]]; then
                        _R_SPIN_USE_DOT=1
                    else
                        _R_SPIN_USE_DOT=0
                    fi
                    (
                        _R_TIMER_RUN=1
                        trap '_R_TIMER_RUN=0' USR1
                        local _r_elapsed _r_st _r_d _r_tw _r_maxd _r_tick=0
                        while (( _R_TIMER_RUN )); do
                            _r_elapsed=$(( $(_timestamp_ms) - _R_SPIN_START_TS ))
                            (( _r_elapsed < 0 )) && _r_elapsed=0
                            if [[ "${_R_SPIN_USE_DOT:-0}" == "1" ]]; then
                                (( _r_tick % 5 == 0 )) && { _dot_tick 2>/dev/null || true; _R_SPIN_ICON="${_DOT_FRAME:-●}"; }
                                _r_st="  ${_R_SPIN_ICON:-●} ${_R_SPIN_LABEL}"
                            else
                                _spin_tick 2>/dev/null || true
                                _r_st="  ${_SPIN_FRAME:-●} ${_R_SPIN_LABEL}"
                            fi
                            _ui_time $_r_elapsed 2>/dev/null || true
                            _r_st+=" (${_UI_TIME:-0s})"
                            # Re-read desc from file so status_update can update it live
                            if [[ -n "${_R_SPIN_DESC_FILE:-}" && -f "$_R_SPIN_DESC_FILE" ]]; then
                                _r_d=$(cat "$_R_SPIN_DESC_FILE" 2>/dev/null)
                            elif [[ -n "${_R_SPIN_DESC:-}" ]]; then
                                _r_d="${_R_SPIN_DESC}"
                            else
                                _r_d=""
                            fi
                            if [[ -n "$_r_d" ]]; then
                                _r_tw="${COLUMNS:-80}"
                                _r_maxd=$(( _r_tw - ${#_r_st} - 4 ))
                                (( _r_maxd < 10 )) && _r_maxd=10
                                (( ${#_r_d} > _r_maxd )) && _r_d="${_r_d:0:$_r_maxd}…"
                                _r_st+=" · ${_r_d}"
                            fi
                            printf '\r%s\033[K' "$_r_st" 2>/dev/null >/dev/tty || break
                            sleep 0.1
                            _r_tick=$((_r_tick + 1))
                        done
                    ) &
                    _R_SPIN_TIMER_PID=$!
                elif [[ "$_type" == "status_begin" ]]; then
                    log "DEBUG: [RENDER] status_begin IGNORED label=[$_label] existing_timer_label=[${_R_SPIN_LABEL:-}] existing_timer_pid=[${_R_SPIN_TIMER_PID:-}]"
                fi
                # /dev/tty bypasses stdio buffering (char device → immediate write()).
                if [[ "${_R_SPIN_USE_DOT:-0}" == "1" && -n "${_R_SPIN_TIMER_PID:-}" ]]; then
                    # Update desc file so timer picks up new count next frame
                    [[ -n "${_R_SPIN_DESC_FILE:-}" ]] && printf '%s' "${_desc:-}" > "$_R_SPIN_DESC_FILE" 2>/dev/null
                elif [[ -w /dev/tty ]]; then
                    local _st_line _d _tw _maxd
                    _st_line="  ${_icon} ${_label} (${_ela})"
                    if [[ -n "$_desc" ]]; then
                        if _detect_termux; then
                            _tw="${COLUMNS:-80}"
                            _maxd=$(( _tw - 25 ))
                            (( _maxd < 10 )) && _maxd=10
                            _d="$_desc"
                            (( ${#_d} > _maxd )) && _d="${_d:0:$_maxd}…"
                        else
                            _d="$_desc"
                        fi
                        _st_line+=" · ${_d}"
                    fi
                    printf '\r%s\033[K' "$_st_line" 2>/dev/null >/dev/tty || true
                    _R_SPIN_DESC="${_desc:-}"
                    if [[ "$_label" != "${_R_SPIN_LABEL:-}" ]]; then
                        log "WARN: [MISMATCH] renderer wrote label=[$_label] but timer has [${_R_SPIN_LABEL:-}] timer_pid=[${_R_SPIN_TIMER_PID:-}]"
                    fi
                else
                    printf '\r  %s %s (%s)' "$_icon" "$_label" "$_ela"
                    [[ -n "$_desc" ]] && printf ' %s·%s %s' "$LIGHT_YELLOW" "$RESET" "$_desc"
                    printf '\033[K\n\033[1A'
                fi
            fi ;;
        tool_start)
            {
                IFS= read -r _TOOL_NAME
                IFS= read -r _TOOL_DESC
            } <<< "$(jq -r '(.name // ""), (.desc // "")' <<< "$_line" 2>/dev/null)" || true
            if _detect_termux; then
                :  # Termux: defer rendering to tool_tick (CUU unavailable)
            else
                printf '    %s└%s %s' "$LIGHT_YELLOW" "$RESET" "$_TOOL_NAME"
                [[ -n "$_TOOL_DESC" ]] && printf ' %s·%s %s' "$LIGHT_YELLOW" "$RESET" "$_TOOL_DESC"
                printf '\n'
            fi ;;
        tool_end)
            log "DEBUG: [RENDER] tool_end _TOOL_NAME=[${_TOOL_NAME:-}] _TOOL_DESC=[${_TOOL_DESC:-}] frame_name=$(jq -r '.name // "?"' <<< "$_line" 2>/dev/null)"
            [[ -z "$_TOOL_NAME" ]] && return 0
            local _ela; _ela=$(jq -r '.elapsed_str // ""' <<< "$_line" 2>/dev/null)
            if _detect_termux; then
                printf '\r\033[K\n' >/dev/tty
            else
                printf '\033[?25l\033[1A\r\033[K\033[?25h'
            fi
            _TOOL_NAME="" _TOOL_DESC="" ;;
        tool_tick)
            local _ela; _ela=$(jq -r '.elapsed_str // ""' <<< "$_line" 2>/dev/null)
            [[ -z "$_TOOL_NAME" ]] && return 0
            if _detect_termux; then
                local _tick_line _tw; _tw="${COLUMNS:-80}"
                _tick_line="    └ ${_TOOL_NAME} (${_ela})"
                [[ -n "$_TOOL_DESC" ]] && _tick_line+=" · ${_TOOL_DESC}"
                # Truncate to prevent line wrap (CUU unavailable on Termux)
                if (( ${#_tick_line} > _tw - 1 )); then
                    _tick_line="${_tick_line:0:$((_tw - 2))}…"
                fi
                printf '\r%s\033[K\r' "$_tick_line" >/dev/tty
            else
                printf '\033[?25l\033[1A\r    %s└%s %s (%s%s%s)' \
                    "$LIGHT_YELLOW" "$RESET" "$_TOOL_NAME" \
                    "$GRAY" "$_ela" "$RESET"
                [[ -n "$_TOOL_DESC" ]] && printf ' %s·%s %s' "$LIGHT_YELLOW" "$RESET" "$_TOOL_DESC"
                printf '\033[K\n\033[?25h'
            fi ;;
        tree)
            local _entries; _entries=$(jq -c '.entries // []' <<< "$_line" 2>/dev/null)
            if [[ -n "$_entries" && "$_entries" != "[]" ]]; then
                local _idx=0 _total; _total=$(jq 'length' <<< "$_entries")
                while IFS= read -r _entry; do
                    [[ -z "$_entry" ]] && continue
                    _idx=$((_idx + 1))
                    local _tn _te _td _branch
                    {
                        IFS= read -r _tn
                        IFS= read -r _te
                    } <<< "$(jq -r '(.name // "?"), (.elapsed_str // "")' <<< "$_entry" 2>/dev/null)" || true
                    _td=$(jq -r '.desc // ""' <<< "$_entry")
                    if (( _idx == _total )); then _branch="└"; else _branch="├"; fi
                    printf '    %s%s%s %s (%s%s%s)' \
                        "$LIGHT_YELLOW" "$_branch" "$RESET" \
                        "$_tn" \
                        "$GRAY" "$_te" "$RESET"
                    [[ -n "$_td" ]] && printf ' %s·%s %s' "$LIGHT_YELLOW" "$RESET" "$_td"
                    printf '\n'
                    local _tc; _tc=$(jq -r '.contents // ""' <<< "$_entry")
                    if [[ -n "$_tc" ]]; then
                        while IFS= read -r _tcl; do
                            if [[ "$_tcl" =~ ^[[:space:]]*[0-9]+\ \+ ]]; then
                                printf '      %s%s%s\033[K%s\n' "$DIFF_ADD_BG" "$DIFF_ADD_FG" "$_tcl" "$RESET"
                            elif [[ "$_tcl" =~ ^[[:space:]]*[0-9]+\ - ]]; then
                                printf '      %s%s%s\033[K%s\n' "$DIFF_DEL_BG" "$DIFF_DEL_FG" "$_tcl" "$RESET"
                            else
                                printf '      %s\n' "$_tcl"
                            fi
                        done <<< "$_tc"
                    fi
                done < <(jq -c '.[]' <<< "$_entries")
            fi
            ;;
        request_pending)
            local _rq _rp _ro _rc _rr
            _rq=$(jq -r '.request_id // ""' <<< "$_line" 2>/dev/null)
            _rp=$(jq -r '.prompt // ""' <<< "$_line" 2>/dev/null)
            _ro=$(jq -c '.options // []' <<< "$_line" 2>/dev/null)
            _rc=$(jq -r '.context // ""' <<< "$_line" 2>/dev/null)
            _rr=$(jq -r '.response_file // ""' <<< "$_line" 2>/dev/null)
            # Call interactive request UI (single source: lib/request_ui.sh)
            if declare -f _request_ui >/dev/null 2>&1 && (exec 2>/dev/tty) 2>/dev/null; then
                local _ui_result _ui_choice
                _ui_result=$(_request_ui "$_rp" "$_ro" "$_rc" 2>/dev/tty)
                # Route response
                if [[ -n "$_rr" ]]; then
                    # Oneshot fallback: write user's choice to response file
                    printf '%s' "$_ui_result" > "$_rr"
                elif [[ -n "$_rq" ]]; then
                    # Daemon SSE: POST /respond to daemon
                    local _port _sid
                    _port=$(jq -r '.daemon_port // "9655"' "$HOME/.bashagt/settings.json" 2>/dev/null || echo "9655")
                    _sid="${_BSHT_SID:-${_bsht_sid:-${_sid:-}}}"
                    _ui_choice=$(jq -r '.choice // ""' <<< "$_ui_result" 2>/dev/null)
                    if [[ -n "$_sid" ]] && [[ -n "$_ui_choice" ]]; then
                        curl -s --max-time 5 -X POST \
                            "http://localhost:$_port/v1/session/$_sid/respond" \
                            -H "Content-Type: application/json" \
                            -d "{\"request_id\":\"$_rq\",\"choice\":\"$_ui_choice\"}" \
                            >/dev/null 2>&1 &
                    fi
                fi
            else
                # Fallback: no tty or request_ui.sh not loaded — show static text
                printf '  %s[Human Oversight Request]%s\n' "$BOLD" "$RESET"
                printf '  %s\n' "$_rp"
                [[ -n "$_rc" ]] && printf '  %s%s%s\n' "$DIM" "$_rc" "$RESET"
                # If oneshot (has response_file), write a default cancelled response
                if [[ -n "$_rr" ]]; then
                    printf '{"result":"cancelled","choice_index":-1}' > "$_rr"
                fi
            fi
            ;;
        *) printf '%s\n' "$_line" ;;
    esac
    return 0
}

# Serialize the canonical _stream_render to ~/.bashagt/lib/render.sh.
# Called during --install and --update so keybinding files share the
# same renderer as interactive mode (single source of truth).
_emit_render_lib() {
    local _libdir="$HOME/.bashagt/lib"
    mkdir -p "$_libdir"
    {
        printf '%s\n' '# bashagt render library — auto-generated, DO NOT EDIT'
        printf '%s\n' '# Source: bashagt --install or --update'
        printf '%s\n' '# Provides: _stream_render() — JSONL-to-ANSI renderer'
        printf '%s\n' ''
        printf '%s\n' '[[ -f "$HOME/.bashagt/lib/request_ui.sh" ]] && source "$HOME/.bashagt/lib/request_ui.sh"'
        printf '%s\n' ''
        # Inline mode-aware color resolver (reads BASHAGT_DARK_MODE at call time)
        printf '%s\n' '_clr() {'
        printf '%s\n' '  if [[ "${BASHAGT_DARK_MODE:-true}" != "false" && "${BASHAGT_DARK_MODE:-true}" != "0" ]]; then'
        printf '%s\n' '    case "$1" in'
        while IFS='|' read -r _name _dark _light _cat; do
            [[ -z "$_name" || "$_name" == '#'* ]] && continue
            case "$_name" in
                err|cmd|meta|ok|sel|dim|reset|bold|dec|\
                diff_add_bg|diff_add_fg|diff_del_bg|diff_del_fg)
                    printf '\t    %s) printf "\\033[%sm" ;;\n' "$_name" "$_dark" ;;
            esac
        done < <(_color_palette)
        printf '%s\n' '    esac'
        printf '%s\n' '  else'
        printf '%s\n' '    case "$1" in'
        while IFS='|' read -r _name _dark _light _cat; do
            [[ -z "$_name" || "$_name" == '#'* ]] && continue
            case "$_name" in
                err|cmd|meta|ok|sel|dim|reset|bold|dec|\
                diff_add_bg|diff_add_fg|diff_del_bg|diff_del_fg)
                    printf '\t    %s) printf "\\033[%sm" ;;\n' "$_name" "$_light" ;;
            esac
        done < <(_color_palette)
        printf '%s\n' '    esac'
        printf '%s\n' '  fi'
        printf '%s\n' '}'
        printf '%s\n' ''
        printf '%s\n' 'DIM=$(_clr dim); RESET=$(_clr reset); BOLD=$(_clr bold)'
        printf '%s\n' 'YELLOW=$(_clr cmd); RED=$(_clr err)'
        printf '%s\n' 'GRAY=$(_clr meta); GREEN=$(_clr ok); LIGHT_YELLOW=$(_clr dec)'
        printf '%s\n' ''
        declare -f _stream_render
    } > "$_libdir/render.sh"
}

# Serialize request UI functions to ~/.bashagt/lib/request_ui.sh.
# Called during --install and --update so hotkey bindings share the
# same interactive request menu as interactive mode (single source of truth).
_emit_request_ui_lib() {
    local _libdir="$HOME/.bashagt/lib"
    mkdir -p "$_libdir"
    {
        printf '%s\n' '# bashagt request UI library — auto-generated, DO NOT EDIT'
        printf '%s\n' '# Source: bashagt --install or --update'
        printf '%s\n' '# Provides: _req_disp_width, _req_content_line, _request_ui_render, _request_ui, _request_resolve'
        printf '%s\n' ''
        printf '%s\n' '# Inline mode-aware color resolver (mirrors render.sh _clr)'
        printf '%s\n' '_clr() {'
        printf '%s\n' '  if [[ "${BASHAGT_DARK_MODE:-true}" != "false" && "${BASHAGT_DARK_MODE:-true}" != "0" ]]; then'
        printf '%s\n' '    case "$1" in'
        printf '\t    bold) printf "\\033[1m" ;;\n'
        printf '\t    dim)  printf "\\033[2m" ;;\n'
        printf '\t    reset) printf "\\033[0m" ;;\n'
        printf '\t    cyan) printf "\\033[38;2;86;156;214m" ;;\n'
        printf '%s\n' '    esac'
        printf '%s\n' '  else'
        printf '%s\n' '    case "$1" in'
        printf '\t    bold) printf "\\033[1m" ;;\n'
        printf '\t    dim)  printf "\\033[2m" ;;\n'
        printf '\t    reset) printf "\\033[0m" ;;\n'
        printf '\t    cyan) printf "\\033[38;2;0;0;255m" ;;\n'
        printf '%s\n' '    esac'
        printf '%s\n' '  fi'
        printf '%s\n' '}'
        printf '%s\n' ''
        printf '%s\n' 'BOLD=$(_clr bold); DIM=$(_clr dim); CYAN=$(_clr cyan); RESET=$(_clr reset)'
        printf '%s\n' ''
        declare -f _req_disp_width
        printf '%s\n' ''
        declare -f _req_content_line
        printf '%s\n' ''
        declare -f _request_ui_render
        printf '%s\n' ''
        declare -f _request_ui
        printf '%s\n' ''
        declare -f _request_resolve
    } > "$_libdir/request_ui.sh"
}

# Unified stderr output funnel. All display goes through this.
# Modes:
#   "begin"  — first line of a new display: "  content\n" (no \r)
#   "update" — overwrite current line: "\r  content\033[K"
#   "end"    — finalize: "\r  $GRAY"content"$RESET\033[K\n"
#   "tree_up" — cursor up N lines (for tree re-render)
#   "tree_line" — single tree line: "\r  content\033[K\n"
_ui_emit() {
    (( BASHAGT_STREAM_MODE )) && return 0
    local _mode="$1" _content="$2" _n="${3:-0}"
    case "$_mode" in
        begin)  printf '  %s\n' "$_content" >&2 ;;
        update) printf '\r  %s\033[K' "$_content" >&2 ;;
        end)    printf '\r  %s%s%s\033[K\n' "$GRAY" "$_content" "$RESET" >&2 ;;
        tree_up)    (( _n > 0 )) && printf '\033[%dA' "$_n" >&2 ;;
        tree_line)  printf '\r  %s\033[K\n' "$_content" >&2 ;;
        *)      printf '  %s\n' "$_content" >&2 ;;
    esac
}

# Composite: build a status line string from components (no stderr output)
# Returns string like: "⠹ Thinking... (2.3s) ↑1.2k ↓0.5k | tool"
ui_statusline() {
    local _icon="$1" _label="$2" _elapsed_ms="$3" _itok="${4:-0}" _otok="${5:-0}" _extra="${6:-}"
    local _s=""
    [[ -n "$_icon" ]] && _s+="$_icon "
    _s+="$_label"
    if [[ -n "$_elapsed_ms" && "$_elapsed_ms" -gt 0 ]]; then
        _s+=" ($(ui_time "$_elapsed_ms"))"
    fi
    local _tok; _tok=$(ui_tokens "$_itok" "$_otok")
    [[ -n "$_tok" ]] && _s+=" $_tok"
    [[ -n "$_extra" ]] && _s+=" ${DIM}${_extra}${RESET}"
    printf '%s' "$_s"
}

# ── Legacy compatibility: _build_status delegates to ui_statusline ──
_build_status() {
    ui_statusline "$@"
}


# ── Tree Renderer ──

# Render a single tree node with connector.
# Usage: ui_treenode <indent> <is_last> <content>
#   indent: "" for root level, "│" for continued parent, " " for ended parent
#   is_last: "true"=└, "false"=├
ui_treenode() {
    local _indent="$1" _is_last="$2" _content="$3"
    local _branch
    [[ "$_is_last" == "true" ]] && _branch="└" || _branch="├"
    printf '%s    %s%s %s' "$_indent" "$_branch" "" "$_content"
}

# Render a tree node's child continuation prefix (for next level's indent)

# Begin animated status line (thinking/streaming/formatting)
status_begin() {
    (( BASHAGT_STREAM_MODE )) && return 0
    printf '  %s' "$1" >&2
    STATUS_ACTIVE=1
}

# Update animated status in-place
status_update() {
    (( BASHAGT_STREAM_MODE )) && return 0
    if (( STATUS_ACTIVE )); then
        printf '\r  %s\033[K' "$1" >&2
    else
        status_begin "$1"
    fi
}

# End animated status — replace spinner with "done" label, close line, keep visible
status_done() {
    (( BASHAGT_STREAM_MODE )) && return 0
    local done_label="$1"  # e.g. "think-done" or "stream-done" or "format-done"
    if (( STATUS_ACTIVE )); then
        printf '\r  %s%s%s\033[K\n' "$GRAY" "$done_label" "$RESET" >&2
        STATUS_ACTIVE=0
    fi
}

# Static info line for completed actions (tools etc.) — prints and stays

# Break status line for inline stderr output, then reopen


# Unified spinner tick — advances global SPINNER_IDX, sets _SPIN_FRAME
_spin_tick() {
    _SPIN_FRAME="${SPINNER[SPINNER_IDX]}"
    SPINNER_IDX=$(( (SPINNER_IDX + 1) % SPINNER_LEN ))
}

# Dot tick for tool execution — alternates ● color white↔light green for dynamic feel.
# Sets _DOT_FRAME to colored ●; phase 0 = light green, phase 1 = default (white).
_DOT_PHASE=0
_DOT_FRAME="●"

_dot_tick() {
    if (( _DOT_PHASE == 0 )); then
        _DOT_FRAME="${LIGHT_GREEN}●${RESET}"
        _DOT_PHASE=1
    else
        _DOT_FRAME="●"
        _DOT_PHASE=0
    fi
}

# Unified async+spinner primitive. Runs cmd in background, animates spinner
# with <label>, captures stdout+exit code to globals _async_out / _async_rc.
# Usage: async_spin [--dot] <label> <done_suffix> <out_file> <cmd...>
#   --dot: use ● flashing (white↔light green) instead of braille spinner
#   Normal mode: calls status_done with gray "label-done (elapsed)"
#   Dot mode: freezes ● at light green with final elapsed, closes line
# Returns: 0 on success (cmd exit 0 + non-empty output), 1 otherwise

# ── Unified interrupt detection (Esc/Ctrl-C) ──
# All spinner/poll loops call these instead of sleep or inline Esc checks.
# _interrupted: pure flag check (set by INT trap)
# _spin_sleep:  sleep + Esc detection (for wait loops)
# _poll_esc:    non-blocking Esc only (for tight/FIFO loops)

_interrupted() {
    [[ "${_bagt_interrupted:-0}" == "1" ]] || return 1
    log "DEBUG: [INT] _interrupted=true caller=${FUNCNAME[1]:-?}:${BASH_LINENO[0]:-0} source=${_INTERRUPT_SOURCE:-?}"
    # Fire on_interrupt hook once (first detection only)
    if [[ "${_HOOK_INTERRUPT_FIRED:-0}" == "0" ]]; then
        _HOOK_INTERRUPT_FIRED=1
        _hook_fire "on_interrupt" "{\"source\":\"${_INTERRUPT_SOURCE:-unknown}\"}" >/dev/null 2>&1 || true
    fi
    return 0
}

_spin_sleep() {
    local _to="${1:-0.1}"
    _interrupted && return 1
    local _ch=""
    if IFS= read -r -s -t "$_to" -n1 _ch 2>/dev/null; then
        if [[ "${_ch:-}" == $'\033' ]]; then
            local _seq="" _ch2=""
            while IFS= read -r -s -t 0.01 -n1 _ch2 2>/dev/null; do _seq+="$_ch2"; done
            [[ -z "$_seq" ]] && { _bagt_interrupted=1; log "DEBUG: [INT] _spin_sleep ESC detected — set _bagt_interrupted=1"; return 1; }
        fi
    fi
    return 0
}

_poll_esc() {
    _interrupted && return 1
    local _ch=""
    if IFS= read -r -s -t 0 -n1 _ch 2>/dev/null; then
        if [[ "${_ch:-}" == $'\033' ]]; then
            local _seq="" _ch2=""
            while IFS= read -r -s -t 0.01 -n1 _ch2 2>/dev/null; do _seq+="$_ch2"; done
            [[ -z "$_seq" ]] && { _bagt_interrupted=1; return 1; }
        fi
    fi
    return 0
}

# ── Hook System ──
# Architecture-level event interceptors. External handlers (bash/python/go/http)
# register for hook points; _hook_fire executes them at the right moment.
# Protocol: context JSON in → instruction JSON out (via stdout).
# All handlers run in isolated subprocesses with timeout; failure is never fatal.

# Hook registry (6 associative arrays, same pattern as AGENTS/SKILLS/MCP_SERVERS)
declare -A HOOK_HANDLERS     # name → handler content (function body / file path / template body)
declare -A HOOK_TYPE         # name → "inline_bash" | "bash" | "python" | "exec" | "http" | "template"
declare -A HOOK_ENABLED      # name → "1"/"0"
declare -A HOOK_PRIORITY     # name → int (lower = earlier)
declare -A HOOK_META         # name → JSON {point, type, desc, source, timeout_ms, version}
declare -A HOOK_POINTS       # point_name → "name1 name2 ..." (space-separated, ordered by priority)

# ── register_hook ──
# Usage: register_hook <point> <priority> <name> <type> <source>
register_hook() {
    local _point="$1" _prio="$2" _name="$3" _type="$4" _source="$5"
    HOOK_TYPE[$_name]="$_type"
    HOOK_HANDLERS[$_name]="$_source"
    HOOK_PRIORITY[$_name]="$_prio"
    HOOK_ENABLED[$_name]="1"
    HOOK_META[$_name]=$(jq -nc --arg point "$_point" --arg type "$_type" --arg name "$_name" \
        '{point:$point, type:$type, name:$name}')
    HOOK_POINTS[$_point]+="$_name "
    log "DEBUG: register_hook name=$_name type=$_type point=$_point priority=$_prio" 2>/dev/null || true
}

# ── _hook_http_call ──
# HTTP-type hook transport. Reuses http_request infrastructure (proxy/retry/timeout).
_hook_http_call() {
    local _url="$1" _timeout="${2:-5000}"
    local _timeout_sec=$(( (_timeout + 999) / 1000 ))
    [[ $_timeout_sec -lt 1 ]] && _timeout_sec=1

    local _body_file; _body_file=$(_mktemp_file "/tmp/bashagt_hook_body.XXXXXX")
    cat > "$_body_file"   # stdin = context JSON

    local _resp_file; _resp_file=$(_mktemp_file "/tmp/bashagt_hook_resp.XXXXXX")

    http_request "POST" "$_url" "$_resp_file" \
        --connect-timeout 3 --max-time "$_timeout_sec" \
        --header "Content-Type: application/json" \
        --body "$_body_file"

    local _rc=$?
    if (( _rc == 0 )); then
        cat "$_resp_file"
    else
        log "Hook HTTP [$_url]: http_request exit=$_rc"
        jq -nc '{inject:false}'
    fi

    rm -f "$_body_file" "$_resp_file"
}

# ── _hook_render_template ──
# Renders a template-type hook body with {{var}} substitution from context JSON.
_hook_render_template() {
    local _file="$1" _ctx="$2"
    local _body; _body=$(< "$_file")
    [[ -z "$_body" ]] && { jq -nc '{inject:false}'; return; }
    local _rendered="$_body"
    local _key _val
    while IFS= read -r _key; do
        _val=$(jq -r --arg k "$_key" '.[$k] // ""' <<< "$_ctx" 2>/dev/null)
        local _pattern="{{${_key}}}"
        _rendered="${_rendered//$_pattern/$_val}"
    done < <(jq -r 'paths(scalars) | join(".")' <<< "$_ctx" 2>/dev/null)
    jq -nc --arg text "$_rendered" '{inject:true, role:"user", content:$text}'
}

# ── _hook_fire ──
# Executes all handlers registered for a hook point, ordered by priority.
# Returns: JSON array of handler output objects (via stdout). Caller reads via $().
# Each handler runs in isolated subprocess with timeout; interrupt-aware.
_hook_fire() {
    local _point="$1" _ctx="${2:-{}}" _out_result="${3:-}"
    local _hook_start_ts; _hook_start_ts=$(_timestamp_ms)
    local _names="${HOOK_POINTS[$_point]:-}"
    if [[ -z "$_names" ]]; then
        if [[ -n "$_out_result" ]]; then
            echo '[]' > "$_out_result"
        else
            echo '[]'
        fi
    fi

    local _name _type _handler _meta _timeout _stderr_file _out_file _result_json _hpid _waited
    _stderr_file=$(_mktemp_file "/tmp/bashagt_hook_stderr.XXXXXX")
    local -a _results=()

    # Sort handlers by priority (lower = earlier)
    local _sorted_names; _sorted_names=$(for _n in $_names; do
        printf '%s %s\n' "${HOOK_PRIORITY[$_n]:-0}" "$_n"
    done 2>/dev/null | sort -n | awk "{print \$2}")
    [[ -z "$_sorted_names" ]] && { echo '[]'; return 0; }

    for _name in $_sorted_names; do
        [[ "${HOOK_ENABLED[$_name]:-0}" != "1" ]] && continue
        _type="${HOOK_TYPE[$_name]:-inline_bash}"
        _handler="${HOOK_HANDLERS[$_name]:-}"
        _meta="${HOOK_META[$_name]:-{}}"
        _timeout=$(jq -r '.timeout_ms // 2000' <<< "$_meta" 2>/dev/null | head -1)
        [[ "$_timeout" =~ ^[0-9]+$ ]] || _timeout=2000

        _interrupted && break

        _out_file=$(_mktemp_file "/tmp/bashagt_hook_out.XXXXXX")
        (
            case "$_type" in
                inline_bash) set +B; eval "$_handler" ;;  # +B=disable brace expansion (protects JSON {})
                bash)        source "$_handler"; "${_name}" "$_ctx" ;;
                python)      echo "$_ctx" | python3 "$_handler" ;;
                exec)        echo "$_ctx" | "$_handler" ;;
                http)        echo "$_ctx" | _hook_http_call "$_handler" "$_timeout" ;;
                template)    _hook_render_template "$_handler" "$_ctx" ;;
                *)           log "Hook [$_name]: unknown type $_type"; exit 0 ;;
            esac
        ) >"$_out_file" 2>"$_stderr_file" &
        _hpid=$!
        _proc_register "$_hpid" "hook" "$_name"

        _waited=0
        while kill -0 $_hpid 2>/dev/null && (( _waited < _timeout )); do
            _spin_sleep 0.02 || { _proc_kill "$_hpid"; break; }
            _waited=$((_waited + 20))
        done
        if kill -0 $_hpid 2>/dev/null; then
            _proc_kill "$_hpid"
            log "Hook [$_name] timed out after ${_timeout}ms"
        fi
        wait $_hpid 2>/dev/null || true

        if [[ -s "$_out_file" ]]; then
            _result_json=$(< "$_out_file")
            [[ -n "$_result_json" ]] && _results+=("$_result_json")
        fi
        rm -f "$_out_file"
    done
    rm -f "$_stderr_file"

    log "DEBUG: HOOK_FIRE  point=$_point results=${#_results[@]} elapsed=$(( $(_timestamp_ms) - _hook_start_ts ))ms"

    local _output='[]'
    if ((${#_results[@]} > 0)); then
        _output=$(printf '%s\n' "${_results[@]}" | jq -sc '.' 2>/dev/null || echo '[]')
    fi
    if [[ -n "$_out_result" ]]; then
        printf '%s' "$_output" > "$_out_result"
    else
        printf '%s' "$_output"
    fi
}

# ── load_hooks ──
# Scans system ($HOME/.bashagt/hooks/) then project (.bashagt/hooks/) directories.
# .md → template, .py → python, .sh → source (script self-registers), +x → exec.
# http hooks are only registered via settings.json declarations (loaded in load_config).
load_hooks() {
    local _dir _file _name _ext _mime _prio _is_project=0
    local -a _dirs=("$HOME/.bashagt/hooks")
    [[ -d ".bashagt/hooks" ]] && _dirs+=(".bashagt/hooks")

    for _dir in "${_dirs[@]}"; do
        [[ -d "$_dir" ]] || continue
        for _file in "$_dir"/*; do
            [[ -f "$_file" ]] || continue
            _name=$(basename "$_file")
            _ext="${_name##*.}"
            [[ "$_ext" == "$_name" ]] && _ext=""

            case "$_ext" in
                md)
                    local _fm _point _desc; _desc=""
                    # Try JSON frontmatter first (single-line or multi-line {...})
                    _point=$(jq -r '.point // ""' "$_file" 2>/dev/null) || true
                    if [[ -n "$_point" ]]; then
                        _prio=$(jq -r '.priority // 10' "$_file" 2>/dev/null) || _prio=10
                        _desc=$(jq -r '.description // ""' "$_file" 2>/dev/null) || true
                    else
                        # Fallback: YAML frontmatter (--- delimiters or bare key:value)
                        # Extract YAML field values with sed (no jq — YAML not JSON)
                        _fm=$(sed -n '/^---$/,/^---$/p' "$_file" 2>/dev/null | head -20) || true
                        [[ -z "$_fm" ]] && _fm=$(< "$_file")
                        _point=$(echo "$_fm" | sed -n 's/^[[:space:]]*point:[[:space:]]*//p' | head -1) || true
                        [[ -z "$_point" ]] && continue
                        _prio=$(echo "$_fm" | sed -n 's/^[[:space:]]*priority:[[:space:]]*//p' | head -1) || _prio=10
                        _desc=$(echo "$_fm" | sed -n 's/^[[:space:]]*description:[[:space:]]*//p' | head -1) || true
                    fi
                    register_hook "$_point" "$_prio" "${_name%.md}" "template" "$_file"
                    ;;
                py)
                    # Python hooks auto-discovered via JSON sidecar file:
                    #   my_hook.py         — Python script
                    #   my_hook.py.json    — {"point":"pre_turn","priority":10}
                    local _meta_file="${_file}.json"
                    if [[ -f "$_meta_file" ]]; then
                        _point=$(jq -r '.point // ""' "$_meta_file" 2>/dev/null) || true
                        [[ -z "$_point" ]] && continue
                        _prio=$(jq -r '.priority // 10' "$_meta_file" 2>/dev/null) || _prio=10
                        register_hook "$_point" "$_prio" "${_name%.py}" "python" "$_file"
                    fi
                    ;;
                sh)
                    # Source the script; it calls register_hook() internally
                    source "$_file" 2>/dev/null || log "Hook: source $_file failed"
                    ;;
                *)
                    # +x executable → auto-discovered via JSON sidecar file:
                    #   my_binary           — executable
                    #   my_binary.json      — {"point":"post_tool","priority":5}
                    if [[ -x "$_file" && ! -d "$_file" ]]; then
                        local _meta_file="${_file}.json"
                        if [[ -f "$_meta_file" ]]; then
                            _point=$(jq -r '.point // ""' "$_meta_file" 2>/dev/null) || true
                            [[ -z "$_point" ]] && continue
                            _prio=$(jq -r '.priority // 10' "$_meta_file" 2>/dev/null) || _prio=10
                            register_hook "$_point" "$_prio" "$_name" "exec" "$_file"
                        fi
                    fi
                    ;;
            esac
        done
    done
    local _hooks_count=0
    [[ -v HOOK_POINTS[@] ]] && _hooks_count=${#HOOK_POINTS[@]}
    log "DEBUG: HOOKS_LOAD count=$_hooks_count"
}

async_spin() {
    local _use_dot=0 _desc=""
    if [[ "${1:-}" == "--dot" ]]; then
        _use_dot=1; shift
    fi
    if [[ "${1:-}" == "--desc" ]]; then
        shift; _desc="$1"; shift
    fi
    local label="$1" done_suffix="$2" out_file="$3"; shift 3

    # ── Single-line spinner mode ──
    local _start_ts _elapsed _now _tick_fn _frame_var _tick_count=0
    if (( _use_dot )); then
        _tick_fn="_dot_tick"; _frame_var="_DOT_FRAME"
    else
        _tick_fn="_spin_tick"; _frame_var="_SPIN_FRAME"
    fi
    _start_ts=$(_timestamp_ms)

    # Bypass fd for _stream_emit: _BYPASS_FD is set to 8 by the stream
    # wrappers (_stream_run_turn / _stream_run_oneshot / raw JSONL path)
    # when fd 8 is available. Fallback to fd 1.
    local _stm_fd=${_BYPASS_FD:-1}

    "$@" 8>&- > "$out_file" 2>/dev/null &
    local _pid=$!

    "$_tick_fn"; status_begin "$(_build_status "${!_frame_var}" "$label" 0 0 0)"
    _stream_emit "status_begin" "$(jq -nc \
        --arg icon "${!_frame_var}" --arg label "$label" --arg ela "0s" --arg desc "$_desc" \
        '{icon: $icon, label: $label, elapsed_str: $ela, desc: $desc}')" >&$_stm_fd
    printf '\033[?25l'
    while kill -0 $_pid 2>/dev/null; do
        _now=$(_timestamp_ms)
        _elapsed=$(( _now - _start_ts ))
        (( _elapsed < 0 )) && _elapsed=0
        if (( _use_dot )); then
            (( _tick_count % 5 == 0 )) && _dot_tick
        else
            _spin_tick
        fi
        status_update "$(_build_status "${!_frame_var}" "$label" "$_elapsed" 0 0)"
        _stream_emit "status_update" "$(jq -nc \
            --arg icon "${!_frame_var}" --arg label "$label" \
            --arg ela "$(ui_time $_elapsed)" --arg desc "$_desc" \
            '{icon: $icon, label: $label, elapsed_str: $ela, desc: $desc}')" >&$_stm_fd
        _spin_sleep || { _pkill_tree "$_pid" TERM 2>/dev/null || true; break; }
        _tick_count=$((_tick_count + 1))
    done
    wait $_pid
    _async_rc=$?
    # NOTE: do NOT clear _bagt_interrupted here — let run_turn see it
    printf '\033[?25h'
    _async_out=$(< "$out_file")
    local _leaked; _leaked=$(grep -c '^{"type":"' "$out_file" 2>/dev/null || true)
    _leaked="${_leaked:-0}"
    (( _leaked > 0 )) && log "WARN: [LEAK] $_leaked frames leaked to agent output file — agent internal fd bypass failed"
    rm -f "$out_file"

    _elapsed=$(( $(_timestamp_ms) - _start_ts ))
    (( _elapsed < 0 )) && _elapsed=0

    _done_label="${label,,}-${done_suffix}"
    if (( _use_dot )) && { [[ $_async_rc -ne 0 ]] || [[ -z "$_async_out" ]]; }; then
        _done_label="${label,,}-error"
    fi
    _stream_emit "status_done" "$(jq -nc \
        --arg label "$_done_label" --arg ela "$(ui_time $_elapsed)" --arg desc "$_desc" \
        '{label: $label, elapsed_str: $ela, desc: $desc}')" >&$_stm_fd
    if [[ $_async_rc -eq 0 ]] && [[ -n "$_async_out" ]]; then
        return 0
    else
        return 1
    fi
}

# Detect terminal width ($COLUMNS → tput → default 80)
detect_term_width() {
    local w="${COLUMNS:-}"
    [[ -z "$w" || "$w" -le 0 ]] && w=$(tput cols 2>/dev/null)
    [[ -z "$w" || "$w" -le 0 ]] && w=80
    printf '%d' "$w"
}
TERM_WIDTH=$(detect_term_width)

# SIGWINCH handler: re-detect terminal width, trigger buffer redraw if input active
_update_term_width() {
    local new_w
    new_w=$(detect_term_width)
    if [[ "$new_w" != "$TERM_WIDTH" ]]; then
        TERM_WIDTH="$new_w"
        # Only redraw if input layer is active (_IN_LINE variable is set by _input_init)
        if [[ "${_IN_LINE+set}" == "set" ]] && [[ "${_IN_MODE:-}" == "line" ]]; then
            _in_buf_redraw
        fi
    fi
}

# Escape-sequence inter-byte timeout in seconds (10ms).
# Terminals deliver full escape sequences in <1ms; 10ms is enough headroom
# while eliminating the PASTE_END leak window from slow timeouts.
_IN_ESC_TIMEOUT=0.01

# Unified elapsed time formatter — 0.1s precision under 60s, integer seconds above.
# Format: 1.3s | 2m 5s | 1h 23m 5s | 1d 23h 10m 5s
# All rendering intervals: 0.1s
elapsed_fmt() {
    local ms=$1 sec tenths
    sec=$(( ms / 1000 ))
    if (( sec < 60 )); then
        tenths=$(( (ms % 1000) / 100 ))
        printf '%d.%ds' "$sec" "$tenths"
        return
    fi
    local min=$(( sec / 60 ))
    sec=$(( sec % 60 ))
    if (( min < 60 )); then
        printf '%dm %ds' "$min" "$sec"
        return
    fi
    local hrs=$(( min / 60 ))
    min=$(( min % 60 ))
    if (( hrs < 24 )); then
        printf '%dh %dm %ds' "$hrs" "$min" "$sec"
        return
    fi
    local days=$(( hrs / 24 ))
    hrs=$(( hrs % 24 ))
    printf '%dd %dh %dm %ds' "$days" "$hrs" "$min" "$sec"
}

token_fmt() {
    local n=$1
    if (( n >= 1000 )); then
        local k=$(( n / 100 ))
        printf '%d.%dk' "$(( k / 10 ))" "$(( k % 10 ))"
    else
        printf '%d' "$n"
    fi
}

# Walk up from CWD (or given path) looking for .bashagt/ directory.
# Mirrors how git finds repository root. Returns path if found, "" otherwise.

# ── zsh keybinding generator (zle widgets, macOS-first) ──
# Called by _install_keybindings. Writes ~/.bashagt/keybindings.zsh.
_gen_keybindings_zsh() {
    local _kbfile="$HOME/.bashagt/keybindings.zsh"
    _emit_render_lib
    _emit_request_ui_lib

    cat > "$_kbfile" << 'ZBEOF'
# bashagt hotkey bindings — sourced by ~/.zshrc
# DO NOT EDIT MANUALLY — regenerated by bashagt --install

# Portable file mtime
_file_mtime() {
    stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0
}

# Dry-run FIFO name (standalone — can't depend on bashagt functions).
_mktemp_u() {
    local _t="${1:-${TMPDIR:-/tmp}/bashagt.XXXXXX}"
    mktemp -u "$_t" 2>/dev/null || printf '%s_%s_%s\n' "${_t%XXXXXX}" "$$" "$RANDOM"
}

[[ -n "${_BSHT_KB_LOADED:-}" ]] && return
_BSHT_KB_LOADED=1

# Locate bashagt binary — absolute path first, fall back to PATH lookup
_bashagt() {
    [[ -x "$HOME/.bashagt/bashagt" ]] && { printf '%s' "$HOME/.bashagt/bashagt"; return; }
    command -v bashagt 2>/dev/null || printf '%s' "bashagt"
}

_BSHT_KB_MTIME=$(_file_mtime "$HOME/.bashagt/keybindings.zsh")
_BSHT_RENDER_MTIME=$(_file_mtime "$HOME/.bashagt/lib/render.sh")

# ── Color palette (shared via _colors_resolve in main script) ──
_colors_resolve

_bsht_daemon_running() {
    local _pid; _pid=$(jq -r '.pid // ""' "$HOME/.bashagt/settings.json" 2>/dev/null)
    [[ -n "$_pid" ]] && kill -0 "$_pid" 2>/dev/null
}

# Validate and return cached session ID. Returns 1 if no valid cached session.
_bsht_ensure_session() {
    local _sid_file="$1" _sid _pid _port="${BASHAGT_DAEMON_PORT:-9655}"
    _pid=$(jq -r '.pid // ""' "$HOME/.bashagt/settings.json" 2>/dev/null)
    if [[ -f "$_sid_file" ]]; then
        local _cached=$(< "$_sid_file")
        _sid="${_cached%%:*}"
        local _cached_pid="${_cached##*:}"
        if [[ -n "$_sid" && "$_cached_pid" == "$_pid" ]]; then
            if curl -s --max-time 1 \
                "http://localhost:$_port/v1/session/$_sid" 2>/dev/null | \
                jq -e '.id // ""' >/dev/null 2>&1; then
                printf '%s' "$_sid"
                return 0
            fi
        fi
    fi
    return 1
}

# Expand PS1 for terminal output using zsh native prompt expansion.
_bsht_ps1_str() {
    print -P "${PS1:-%m:%50<...<%~%# }"
}

# ── Renderer (sourced from shared library) ──
if [[ -f "$HOME/.bashagt/lib/render.sh" ]]; then
    source "$HOME/.bashagt/lib/render.sh"
elif ! declare -f _stream_render >/dev/null 2>&1; then
    printf '[bashagt] render library missing — run: bashagt --update\n' >&2
    _stream_render() {
        local _line="$1"
        [[ "$_line" == "{"* ]] || { printf '%s\n' "$_line"; return 0; }
        local _type
    _type="${_line#\{\"type\":\"}"
    _type="${_type%%\"*}"
    [[ -z "$_type" ]] && { printf '%s\n' "$_line"; return 0; }
        case "$_type" in
            done) return 1 ;;
            text|thinking)
                local _c; _c=$(jq -r '.content//""' <<< "$_line" 2>/dev/null)
                printf '%s\n' "$_c" ;;
            warning|error|info)
                local _c; _c=$(jq -r '.content//""' <<< "$_line" 2>/dev/null)
                printf '  %s\n' "$_c" ;;
            status_begin|status_update|status_done) ;;
            tool_start)
                { IFS= read -r _fn_name; IFS= read -r _fn_desc; } <<< "$(jq -r '(.name//""), (.desc//"")' <<< "$_line" 2>/dev/null)" || true
                if _detect_termux; then
                    :  # Termux: defer rendering to tool_tick
                else
                    printf '    └ %s' "$_fn_name"
                    [[ -n "$_fn_desc" ]] && printf ' · %s' "$_fn_desc"
                    printf '\n'
                fi ;;
            tool_end)
                if _detect_termux; then
                    printf '\r\033[K\n' >/dev/tty
                else
                    printf '\033[?25l\033[1A\r\033[K\033[?25h'
                fi
                _fn_name="" _fn_desc="" ;;
            tool_tick)
                local _e; _e=$(jq -r '.elapsed_str//""' <<< "$_line" 2>/dev/null)
                [[ -z "$_fn_name" ]] && return 0
                if _detect_termux; then
                    printf '\r    └ %s (%s)\033[K\r' "$_fn_name" "$_e" >/dev/tty
                else
                    printf '\033[?25l\033[1A\r    └ %s (%s)' "$_fn_name" "$_e"; printf '\033[K\n'
                    [[ -n "$_fn_desc" ]] && printf ' · %s' "$_fn_desc"
                    printf '\033[?25h'
                fi ;;
            tree) ;;
            *) printf '%s\n' "$_line" ;;
        esac
        return 0
    }
fi

# Core handler: send BUFFER to bashagt, display response.
_bsht_send() {
    setopt LOCAL_OPTIONS NO_MONITOR
    local _level="$1" _proj_root="$2"
    local _prompt="${BUFFER:-}" _port="${BASHAGT_DAEMON_PORT:-9655}"

    [[ -z "$_prompt" ]] && { BUFFER=""; return 0; }

    # Add to zsh history so up/down arrows can recall hotkey-triggered inputs.
    print -s "$_prompt"

    # Reconstruct the full prompt line before clearing BUFFER.
    printf '%s%s\n' "$(_bsht_ps1_str)" "$_prompt"
    BUFFER=""

    local _sid _sid_file
    if [[ "$_level" == "system" ]]; then
        _sid_file="$HOME/.bashagt/.hotkey_sys_sid"
    else
        _sid_file="$_proj_root/.bashagt/.hotkey_sid"
    fi

    if _bsht_daemon_running; then
        _sid=$(_bsht_ensure_session "$_sid_file" 2>/dev/null)
        local _body; _body=$(jq -nc --arg p "$_prompt" '{prompt: $p}')
        local _resp
        if [[ -n "$_sid" ]]; then
            _resp=$(curl -s --max-time 2 -X POST \
                -H "Content-Type: application/json" \
                -d "$_body" \
                "http://localhost:$_port/v1/session/$_sid" 2>/dev/null)
        else
            _resp=$(curl -s --max-time 2 -X POST \
                -H "Content-Type: application/json" \
                -d "$_body" \
                "http://localhost:$_port/v1/chat" 2>/dev/null)
            _sid=$(jq -r '.session_id // ""' <<< "$_resp" 2>/dev/null)
            local _pid; _pid=$(jq -r '.pid // ""' "$HOME/.bashagt/settings.json" 2>/dev/null)
            [[ -n "$_sid" ]] && printf '%s:%s' "$_sid" "$_pid" > "$_sid_file"
        fi
        if [[ -n "$_resp" ]]; then
            local _stream; _stream=$(jq -r '.stream_url // ""' <<< "$_resp" 2>/dev/null)
            _BSHT_SID="$_sid"  # expose to _stream_render for request /respond routing
            if [[ -n "$_stream" ]]; then
                local _rfifo; _rfifo=$(_mktemp_u /tmp/bashagt_kb.XXXXXX)
                mkfifo "$_rfifo" 2>/dev/null || {
                    while IFS= read -r _line; do
                        [[ "$_line" == data:\ * ]] && _line="${_line#data: }"
                        _stream_render "$_line" || break
                    done < <(curl -s --max-time 300 -N \
                        "http://localhost:$_port$_stream" 2>/dev/null)
                    printf '\n'
                    return
                }
                exec 3>&2 2>/dev/null
                curl -s --max-time 300 -N \
                    "http://localhost:$_port$_stream" 2>/dev/null > "$_rfifo" &
                local _cpid=$!
                exec 2>&3 3>&-
                while IFS= read -r _line; do
                    [[ "$_line" == data:\ * ]] && _line="${_line#data: }"
                    _stream_render "$_line" || break
                done < "$_rfifo"
                kill $_cpid 2>/dev/null || true
                wait $_cpid 2>/dev/null || true
                rm -f "$_rfifo"
            fi
        else
            printf '[bashagt] Could not create daemon session\n'
        fi
    else
        # Oneshot fallback
        local _rfifo; _rfifo=$(_mktemp_u /tmp/bashagt_kb.XXXXXX)
        if mkfifo "$_rfifo" 2>/dev/null; then
            exec 3>&2 2>/dev/null
            if [[ "$_level" == "project" ]]; then
                (printf '%s' "$_prompt" | BASHAGT_PROJECT_DIR="$_proj_root" "$(_bashagt)" --oneshot --stream 2>/dev/null > "$_rfifo") &
            else
                (printf '%s' "$_prompt" | "$(_bashagt)" --oneshot --stream 2>/dev/null > "$_rfifo") &
            fi
            local _cpid=$!
            exec 2>&3 3>&-
            while IFS= read -r _line; do _stream_render "$_line" || break; done < "$_rfifo"
            wait $_cpid 2>/dev/null || true
            rm -f "$_rfifo"
        else
            if [[ "$_level" == "project" ]]; then
                printf '%s' "$_prompt" | BASHAGT_PROJECT_DIR="$_proj_root" "$(_bashagt)" --oneshot --stream 2>/dev/null | \
                while IFS= read -r _line; do _stream_render "$_line" || break; done
            else
                printf '%s' "$_prompt" | "$(_bashagt)" --oneshot --stream 2>/dev/null | \
                while IFS= read -r _line; do _stream_render "$_line" || break; done
            fi
        fi
    fi
    printf '\n'
}

# Walk up from PWD to find nearest .bashagt/ directory.
_bsht_find_project() {
    local _dir="$PWD"
    while [[ -n "$_dir" && "$_dir" != "/" ]]; do
        [[ -d "$_dir/.bashagt" ]] && { printf '%s' "$_dir"; return 0; }
        _dir=$(dirname "$_dir")
    done
    return 1
}

# Auto-reload: detect if keybindings.zsh or render.sh was updated, re-source if so.
_bsht_auto_reload() {
    local _cur; _cur=$(_file_mtime "$HOME/.bashagt/keybindings.zsh")
    local _rcur; _rcur=$(_file_mtime "$HOME/.bashagt/lib/render.sh")
    [[ "$_cur" == "${_BSHT_KB_MTIME:-0}" && "$_rcur" == "${_BSHT_RENDER_MTIME:-0}" ]] && return 0
    unset _BSHT_KB_LOADED _BSHT_KB_MTIME _BSHT_RENDER_MTIME
    source "$HOME/.bashagt/keybindings.zsh"
    return 1
}

# ── Cmd+G / Ctrl+G: system-level trigger ──
_bsht_on_system() {
    _bsht_auto_reload || { _bsht_on_system; return; }
    _bsht_send system ""
}
zle -N _bsht_on_system

# ── Cmd+T / Ctrl+T: project-level trigger ──
_bsht_on_project() {
    _bsht_auto_reload || { _bsht_on_project; return; }
    local _root; _root=$(_bsht_find_project)
    if [[ -z "$_root" ]]; then
        BUFFER=""
        zle -M "[bashagt] No .bashagt/ directory found. Run bashagt first in this project."
        return 1
    fi
    _bsht_send project "$_root"
}
zle -N _bsht_on_project

# Install the bindings
bindkey '^G' _bsht_on_system   2>/dev/null || true
bindkey '^T' _bsht_on_project  2>/dev/null || true
ZBEOF

    chmod +x "$_kbfile"
}

# ── macOS terminal detection ──
# Returns terminal name on stdout (iterm2, kitty, ghostty, alacritty, wezterm,
# terminal_app) or empty string if not on macOS / unknown terminal.
_detect_macos_terminal() {
    [[ "$(uname -s)" == "Darwin" ]] || return 1

    [[ "$TERM_PROGRAM" == "iTerm.app" ]]     && { printf 'iterm2'; return 0; }
    [[ "$TERM_PROGRAM" == "Apple_Terminal" ]] && { printf 'terminal_app'; return 0; }
    [[ "$TERM" == "xterm-kitty" ]]           && { printf 'kitty'; return 0; }
    [[ "$TERM" == "xterm-ghostty" ]]         && { printf 'ghostty'; return 0; }
    [[ -n "${ALACRITTY_SOCKET:-}" ]]         && { printf 'alacritty'; return 0; }
    [[ -n "${WEZTERM_PANE:-}" ]]             && { printf 'wezterm'; return 0; }
    return 1
}

# ── macOS Cmd key terminal config ──
# Writes/prints terminal-specific config to map Cmd+G→Ctrl+G, Cmd+T→Ctrl+T.
# Called by _install_keybindings on macOS only.
_install_cmd_key_config() {
    local _term="$1"

    case "$_term" in
        kitty)
            local _cfg="$HOME/.config/kitty/kitty.conf"
            if [[ -f "$_cfg" ]]; then
                if ! grep -q 'map cmd+g send_text all' "$_cfg" 2>/dev/null; then
                    printf '\n# bashagt: Cmd+G/Cmd+T -> Ctrl+G/Ctrl+T\n' >> "$_cfg"
                    printf 'map cmd+g send_text all \\x07\n' >> "$_cfg"
                    printf 'map cmd+t send_text all \\x14\n' >> "$_cfg"
                    log "Added Cmd key mappings to $_cfg"
                fi
            else
                printf '  %s %s[%s]%s Kitty config not found at %s — create it and add:\n' \
                    "$YELLOW" "$DIM" "bashagt" "$RESET" "$_cfg"
                printf '    map cmd+g send_text all \\x07\n'
                printf '    map cmd+t send_text all \\x14\n'
            fi
            ;;
        ghostty)
            local _cfg="$HOME/.config/ghostty/config"
            local _cfg2="$HOME/Library/Application Support/com.mitchellh.ghostty/config"
            [[ -f "$_cfg" ]] && _cfg_target="$_cfg"
            [[ -f "$_cfg2" ]] && _cfg_target="$_cfg2"
            if [[ -n "${_cfg_target:-}" ]]; then
                if ! grep -q 'cmd+g=text' "$_cfg_target" 2>/dev/null; then
                    printf '\n# bashagt: Cmd+G/Cmd+T -> Ctrl+G/Ctrl+T\n' >> "$_cfg_target"
                    printf 'keybind = cmd+g=text:\\x07\n' >> "$_cfg_target"
                    printf 'keybind = cmd+t=text:\\x14\n' >> "$_cfg_target"
                    log "Added Cmd key mappings to $_cfg_target"
                fi
            else
                printf '  %s %s[%s]%s Ghostty config not found. Add these lines:\n' \
                    "$YELLOW" "$DIM" "bashagt" "$RESET"
                printf '    keybind = cmd+g=text:\\x07\n'
                printf '    keybind = cmd+t=text:\\x14\n'
            fi
            ;;
        iterm2)
            printf '  %s %s[%s]%s iTerm2 detected. Add Cmd key mappings:\n' \
                "$CYAN" "$DIM" "bashagt" "$RESET"
            printf '    Preferences → Keys → Key Bindings → +\n'
            printf '    Cmd+G → Action: Send Hex Code → 0x07\n'
            printf '    Cmd+T → Action: Send Hex Code → 0x14\n'
            printf '    (Cmd+T may conflict with New Tab — remove default binding first)\n'
            ;;
        alacritty)
            printf '  %s %s[%s]%s Alacritty detected. Add to your config:\n' \
                "$CYAN" "$DIM" "bashagt" "$RESET"
            printf '    [keyboard]\n'
            printf '    bindings = [\n'
            printf '      { key = "G", mods = "Command", chars = "\\x07" },\n'
            printf '      { key = "T", mods = "Command", chars = "\\x14" },\n'
            printf '    ]\n'
            ;;
        wezterm)
            printf '  %s %s[%s]%s WezTerm detected. Add to wezterm.lua:\n' \
                "$CYAN" "$DIM" "bashagt" "$RESET"
            printf '    {key="g", mods="SUPER", action=wezterm.action.SendString("\\x07")},\n'
            printf '    {key="t", mods="SUPER", action=wezterm.action.SendString("\\x14")},\n'
            ;;
        terminal_app)
            printf '  %s %s[%s]%s macOS Terminal.app does not support forwarding Cmd keys.\n' \
                "$YELLOW" "$DIM" "bashagt" "$RESET"
            printf '    Options:\n'
            printf '    1. Switch to iTerm2, Kitty, or Ghostty (free, better Cmd key support)\n'
            printf '    2. Install Karabiner-Elements to remap Cmd→Ctrl in terminal\n'
            printf '    For now: use Ctrl+G / Ctrl+T instead.\n'
            ;;
        *)
            printf '  %s %s[%s]%s Could not detect terminal. Map Cmd+G→Ctrl+G, Cmd+T→Ctrl+T\n' \
                "$YELLOW" "$DIM" "bashagt" "$RESET"
            printf '    in your terminal emulator settings.\n'
            ;;
    esac
}

# ── Termux extra-keys documentation ──
# Prints Termux-specific hotkey configuration instructions.
_install_termux_keys_config() {
    printf '  %s %s[%s]%s Termux/Android detected. For hotkey triggers:\n' \
        "$CYAN" "$DIM" "bashagt" "$RESET"
    printf '  Edit ~/.termux/termux.properties and add:\n'
    printf '    extra-keys = [[{key: Ctrl, display: "G"}, {key: Ctrl, display: "T"}]]\n'
    printf '  Then: termux-reload-settings\n'
    printf '  Or with volume buttons: volume-up = "bashagt --oneshot --stream"\n'
    printf '  Install Hacker'"'"'s Keyboard for full Ctrl key support.\n'
}

# ── Keybinding installation (Ctrl+G system, Ctrl+T project) ──
# Generate both bash and zsh keybinding files, inject into rc files,
# and configure Cmd key mapping on macOS.
# Called during --install flow. Idempotent.
_install_keybindings() {
    local _kbfile="$HOME/.bashagt/keybindings.sh"
    _emit_render_lib
    _emit_request_ui_lib

    cat > "$_kbfile" << 'KBEOF'
# bashagt hotkey bindings — sourced by ~/.bashrc
# DO NOT EDIT MANUALLY — regenerated by bashagt --install

# Portable file mtime — must be defined before first use (keybindings.sh is
# sourced independently from bashagt, so it can't depend on functions there).
_file_mtime() {
    stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0
}

# Dry-run FIFO name (standalone — can't depend on bashagt functions).
_mktemp_u() {
    local _t="${1:-${TMPDIR:-/tmp}/bashagt.XXXXXX}"
    mktemp -u "$_t" 2>/dev/null || printf '%s_%s_%s\n' "${_t%XXXXXX}" "$$" "$RANDOM"
}

[[ -n "${_BSHT_KB_LOADED:-}" ]] && return
_BSHT_KB_LOADED=1

# Locate bashagt binary — absolute path first, fall back to PATH lookup
_bashagt() {
    [[ -x "$HOME/.bashagt/bashagt" ]] && { printf '%s' "$HOME/.bashagt/bashagt"; return; }
    command -v bashagt 2>/dev/null || printf '%s' "bashagt"
}

_BSHT_KB_MTIME=$(_file_mtime "$HOME/.bashagt/keybindings.sh")
_BSHT_RENDER_MTIME=$(_file_mtime "$HOME/.bashagt/lib/render.sh")

# ── Color palette (shared via _colors_resolve in main script) ──
_colors_resolve

_bsht_daemon_running() {
    local _pid; _pid=$(jq -r '.pid // ""' "$HOME/.bashagt/settings.json" 2>/dev/null)
    [[ -n "$_pid" ]] && kill -0 "$_pid" 2>/dev/null
}

# Validate and return cached session ID. Returns 1 if no valid cached session.
# Session creation is now done via POST /v1/chat in _bsht_send (single request).
_bsht_ensure_session() {
    local _sid_file="$1" _sid _pid _port="${BASHAGT_DAEMON_PORT:-9655}"
    _pid=$(jq -r '.pid // ""' "$HOME/.bashagt/settings.json" 2>/dev/null)
    if [[ -f "$_sid_file" ]]; then
        local _cached=$(< "$_sid_file")  # format: "sid:pid"
        _sid="${_cached%%:*}"
        local _cached_pid="${_cached##*:}"
        if [[ -n "$_sid" && "$_cached_pid" == "$_pid" ]]; then
            if curl -s --max-time 1 \
                "http://localhost:$_port/v1/session/$_sid" 2>/dev/null | \
                jq -e '.id // ""' >/dev/null 2>&1; then
                printf '%s' "$_sid"
                return 0
            fi
        fi
    fi
    return 1
}

# Expand PS1 for terminal output. bash 4.4+ uses ${PS1@P}; older
# versions get manual expansion of the common \u@\h:\w\$ pattern.
_bsht_ps1_str() {
    if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )); then
        printf '%s' "${PS1@P}"
    else
        local _p="${PS1:-\\u@\\h:\\w\\\$ }"
        _p="${_p//\\[/}"; _p="${_p//\\]/}"
        _p="${_p//\\u/${USER}}"
        _p="${_p//\\h/${HOSTNAME%%.*}}"
        _p="${_p//\\w/${PWD/#$HOME/~}}"
        _p="${_p//\\W/${PWD##*/}}"
        _p="${_p//\\\$/$}"
        _p="${_p//\\e/$'\033'}"
        printf '%s' "$_p"
    fi
}

# ── Renderer (sourced from shared library) ──
if [[ -f "$HOME/.bashagt/lib/render.sh" ]]; then
    source "$HOME/.bashagt/lib/render.sh"
elif ! declare -f _stream_render >/dev/null 2>&1; then
    printf '[bashagt] render library missing — run: bashagt --update\n' >&2
    _stream_render() {
        local _line="$1"
        [[ "$_line" == "{"* ]] || { printf '%s\n' "$_line"; return 0; }
        local _type
    _type="${_line#\{\"type\":\"}"
    _type="${_type%%\"*}"
    [[ -z "$_type" ]] && { printf '%s\n' "$_line"; return 0; }
        case "$_type" in
            done) return 1 ;;
            text|thinking)
                local _c; _c=$(jq -r '.content//""' <<< "$_line" 2>/dev/null)
                printf '%s\n' "$_c" ;;
            warning|error|info)
                local _c; _c=$(jq -r '.content//""' <<< "$_line" 2>/dev/null)
                printf '  %s\n' "$_c" ;;
            status_begin|status_update|status_done) ;;
            tool_start)
                { IFS= read -r _fn_name; IFS= read -r _fn_desc; } <<< "$(jq -r '(.name//""), (.desc//"")' <<< "$_line" 2>/dev/null)" || true
                if _detect_termux; then
                    :  # Termux: defer rendering to tool_tick
                else
                    printf '    └ %s' "$_fn_name"
                    [[ -n "$_fn_desc" ]] && printf ' · %s' "$_fn_desc"
                    printf '\n'
                fi ;;
            tool_end)
                if _detect_termux; then
                    printf '\r\033[K\n' >/dev/tty
                else
                    printf '\033[?25l\033[1A\r\033[K\033[?25h'
                fi
                _fn_name="" _fn_desc="" ;;
            tool_tick)
                local _e; _e=$(jq -r '.elapsed_str//""' <<< "$_line" 2>/dev/null)
                [[ -z "$_fn_name" ]] && return 0
                if _detect_termux; then
                    printf '\r    └ %s (%s)\033[K\r' "$_fn_name" "$_e" >/dev/tty
                else
                    printf '\033[?25l\033[1A\r    └ %s (%s)' "$_fn_name" "$_e"; printf '\033[K\n'
                    [[ -n "$_fn_desc" ]] && printf ' · %s' "$_fn_desc"
                    printf '\033[?25h'
                fi ;;
            tree) ;;
            *) printf '%s\n' "$_line" ;;
        esac
        return 0
    }
fi

# Core handler: send READLINE_LINE to bashagt, display response
_bsht_send() {
    local _level="$1" _proj_root="$2"
    local _prompt="${READLINE_LINE:-}" _port="${BASHAGT_DAEMON_PORT:-9655}"

    [[ -z "$_prompt" ]] && { READLINE_LINE=""; return 0; }

    # Add to bash history so up/down arrows can recall hotkey-triggered inputs
    history -s "$_prompt"

    # Reconstruct the full prompt line. readline unconditionally redraws PS1
    # after bind -x returns, erasing the original. We print it explicitly so
    # the user sees "user@host:path$ input" in scrollback instead of just "input".
    printf '%s%s\n' "$(_bsht_ps1_str)" "$_prompt"
    READLINE_LINE=""

    local _sid _sid_file
    if [[ "$_level" == "system" ]]; then
        _sid_file="$HOME/.bashagt/.hotkey_sys_sid"
    else
        _sid_file="$_proj_root/.bashagt/.hotkey_sid"
    fi

    if _bsht_daemon_running; then
        _sid=$(_bsht_ensure_session "$_sid_file" 2>/dev/null)
        local _body; _body=$(jq -nc --arg p "$_prompt" '{prompt: $p}')
        local _resp
        if [[ -n "$_sid" ]]; then
            # Cached session valid — send to existing session
            _resp=$(curl -s --max-time 2 -X POST \
                -H "Content-Type: application/json" \
                -d "$_body" \
                "http://localhost:$_port/v1/session/$_sid" 2>/dev/null)
        else
            # No session — use combined create+send (single request, no race)
            _resp=$(curl -s --max-time 2 -X POST \
                -H "Content-Type: application/json" \
                -d "$_body" \
                "http://localhost:$_port/v1/chat" 2>/dev/null)
            _sid=$(jq -r '.session_id // ""' <<< "$_resp" 2>/dev/null)
            local _pid; _pid=$(jq -r '.pid // ""' "$HOME/.bashagt/settings.json" 2>/dev/null)
            [[ -n "$_sid" ]] && printf '%s:%s' "$_sid" "$_pid" > "$_sid_file"
        fi
        if [[ -n "$_resp" ]]; then
            local _stream; _stream=$(jq -r '.stream_url // ""' <<< "$_resp" 2>/dev/null)
            _BSHT_SID="$_sid"  # expose to _stream_render for request /respond routing
            if [[ -n "$_stream" ]]; then
                # FIFO avoids pipe subshell — while loop runs in current shell,
                # preventing jq child processes from corrupting readline's tty.
                local _rfifo; _rfifo=$(_mktemp_u /tmp/bashagt_kb.XXXXXX)
                mkfifo "$_rfifo" 2>/dev/null || {
                    # FIFO failed — fall back to process substitution
                    # (not pipe — pipe would run while in subshell, making
                    # return/break useless)
                    while IFS= read -r _line; do
                        [[ "$_line" == data:\ * ]] && _line="${_line#data: }"
                        _stream_render "$_line" || break
                    done < <(curl -s --max-time 300 -N \
                        "http://localhost:$_port$_stream" 2>/dev/null)
                    printf '\n'
                    return
                }
                # Suppress "[1] PID" — shell prints it to stderr on "&".
                # Redirect stderr to /dev/null for the & line, restore after.
                exec 3>&2 2>/dev/null
                curl -s --max-time 300 -N \
                    "http://localhost:$_port$_stream" 2>/dev/null > "$_rfifo" &
                local _cpid=$!
                exec 2>&3 3>&-
                while IFS= read -r _line; do
                    [[ "$_line" == data:\ * ]] && _line="${_line#data: }"
                    _stream_render "$_line" || break
                done < "$_rfifo"
                kill $_cpid 2>/dev/null || true
                wait $_cpid 2>/dev/null || true
                rm -f "$_rfifo"
            fi
        else
            printf '[bashagt] Could not create daemon session\n'
        fi
    else
        # Oneshot fallback — daemon not running. FIFO avoids pipe subshell.
        local _rfifo; _rfifo=$(_mktemp_u /tmp/bashagt_kb.XXXXXX)
        if mkfifo "$_rfifo" 2>/dev/null; then
            # Suppress "[1] PID" — shell prints it to stderr on "&".
            # Redirect stderr to /dev/null for the & line, restore after.
            exec 3>&2 2>/dev/null
            if [[ "$_level" == "project" ]]; then
                (printf '%s' "$_prompt" | BASHAGT_PROJECT_DIR="$_proj_root" "$(_bashagt)" --oneshot --stream 2>/dev/null > "$_rfifo") &
            else
                (printf '%s' "$_prompt" | "$(_bashagt)" --oneshot --stream 2>/dev/null > "$_rfifo") &
            fi
            local _cpid=$!
            exec 2>&3 3>&-
            while IFS= read -r _line; do _stream_render "$_line" || break; done < "$_rfifo"
            wait $_cpid 2>/dev/null || true
            rm -f "$_rfifo"
        else
            # FIFO failed — fall back to pipe
            if [[ "$_level" == "project" ]]; then
                printf '%s' "$_prompt" | BASHAGT_PROJECT_DIR="$_proj_root" "$(_bashagt)" --oneshot --stream 2>/dev/null | \
                while IFS= read -r _line; do _stream_render "$_line" || break; done
            else
                printf '%s' "$_prompt" | "$(_bashagt)" --oneshot --stream 2>/dev/null | \
                while IFS= read -r _line; do _stream_render "$_line" || break; done
            fi
        fi
    fi
    printf '\n'
}

# Walk up from PWD to find nearest .bashagt/ directory
_bsht_find_project() {
    local _dir="$PWD"
    while [[ -n "$_dir" && "$_dir" != "/" ]]; do
        [[ -d "$_dir/.bashagt" ]] && { printf '%s' "$_dir"; return 0; }
        _dir=$(dirname "$_dir")
    done
    return 1
}

# Auto-reload: detect if keybindings.sh or render.sh was updated, re-source if so
_bsht_auto_reload() {
    local _cur; _cur=$(_file_mtime "$HOME/.bashagt/keybindings.sh")
    local _rcur; _rcur=$(_file_mtime "$HOME/.bashagt/lib/render.sh")
    [[ "$_cur" == "${_BSHT_KB_MTIME:-0}" && "$_rcur" == "${_BSHT_RENDER_MTIME:-0}" ]] && return 0
    unset _BSHT_KB_LOADED _BSHT_KB_MTIME _BSHT_RENDER_MTIME
    source "$HOME/.bashagt/keybindings.sh"
    return 1
}

# ── Ctrl+G: system-level trigger ──
_bsht_on_system() {
    _bsht_auto_reload || { _bsht_on_system; return; }
    _bsht_send system ""
}

# ── Ctrl+T: project-level trigger ──
_bsht_on_project() {
    _bsht_auto_reload || { _bsht_on_project; return; }
    local _root; _root=$(_bsht_find_project)
    if [[ -z "$_root" ]]; then
        READLINE_LINE=""
        printf '\n[bashagt] No .bashagt/ directory found. Run bashagt first in this project.\n'
        return 1
    fi
    _bsht_send project "$_root"
}

# Install the bindings
bind -x '"\C-g": _bsht_on_system'  2>/dev/null || true
bind -x '"\C-t": _bsht_on_project' 2>/dev/null || true
KBEOF

    chmod +x "$_kbfile"

    # ── Generate zsh keybinding file ──
    _gen_keybindings_zsh

    # ── Inject sourcing line into ~/.bashrc ──
    local _bashrc="$HOME/.bashrc"
    local _src_line='source "$HOME/.bashagt/keybindings.sh"  # bashagt hotkeys'
    if [[ -f "$_bashrc" ]]; then
        if ! grep -qF 'bashagt hotkeys' "$_bashrc" 2>/dev/null; then
            printf '\n%s\n' "$_src_line" >> "$_bashrc"
            log "Added keybinding source line to ~/.bashrc"
        fi
    else
        printf '%s\n' "$_src_line" > "$_bashrc"
        log "Created ~/.bashrc with keybinding source line"
    fi

    # ── Inject sourcing line into ~/.zshrc ──
    local _zshrc="$HOME/.zshrc"
    local _zsrc_line='source "$HOME/.bashagt/keybindings.zsh"  # bashagt hotkeys'
    # zsh: use ${ZDOTDIR:-$HOME} but .zshrc is almost always at ~/.zshrc
    if [[ -f "$_zshrc" ]]; then
        if ! grep -qF 'bashagt hotkeys' "$_zshrc" 2>/dev/null; then
            printf '\n%s\n' "$_zsrc_line" >> "$_zshrc"
            log "Added keybinding source line to ~/.zshrc"
        fi
    elif command -v zsh >/dev/null 2>&1; then
        # zsh is installed but no .zshrc yet — create it
        printf '%s\n' "$_zsrc_line" > "$_zshrc"
        log "Created ~/.zshrc with keybinding source line"
    fi

    # ── Ensure user bin directory is in PATH (platform-aware) ──
    _ensure_path_entry "$(_find_user_bin)"

    # ── macOS: detect terminal and configure Cmd key mapping ──
    local _macos_term
    _macos_term=$(_detect_macos_terminal 2>/dev/null || echo "")
    if [[ -n "$_macos_term" ]]; then
        _install_cmd_key_config "$_macos_term"
    fi

    # ── Termux: document extra-keys configuration ──
    if _detect_termux; then
        _install_termux_keys_config
    fi

    log "Keybindings installed: Ctrl+G/Cmd+G → system, Ctrl+T/Cmd+T → project"
}

check_deps() {
    for cmd in curl jq; do
        command -v "$cmd" >/dev/null 2>&1 || die "Missing required command: $cmd"
    done
    # timeout: try gtimeout (macOS coreutils) then timeout (Linux)
    if command -v gtimeout >/dev/null 2>&1; then
        TIMEOUT_CMD="gtimeout"
    elif command -v timeout >/dev/null 2>&1; then
        TIMEOUT_CMD="timeout"
    else
        TIMEOUT_CMD=""  # macOS without coreutils — bash tool has no timeout protection
    fi
    # nl: GNU/BSD compatible
    command -v nl >/dev/null 2>&1 || {
        if _detect_termux; then
            die "Missing required command: nl (install with: pkg install coreutils)"
        else
            die "Missing required command: nl"
        fi
    }
    # HTTP daemon transport: detect only in daemon mode (saves ~200ms in
    # interactive/oneshot modes). socat > nc (serial fallback).
    DAEMON_TRANSPORT=""  # "socat" | "nc"
    if [[ "${BASHAGT_MODE:-}" == "run" ]]; then
        if command -v socat >/dev/null 2>&1; then
            DAEMON_TRANSPORT="socat"
        elif command -v nc >/dev/null 2>&1; then
            DAEMON_TRANSPORT="nc"
            # Functional nc listen-flag probe (covers Linux-OpenBSD, macOS BSD, busybox, ncat)
            _nc_detect_listen || { DAEMON_TRANSPORT="nc"; NC_LISTEN=(nc -l -p); }
            log "WARN: socat not found — HTTP accept will be single-connection (serial)"
            log "  Install socat for parallel connections: apt install socat / brew install socat"
        else
            log "WARN: neither socat nor nc found — daemon mode unavailable"
        fi
    fi
    # Bash version check (4.0 minimum: declare -A, fractional read -t, indirect array keys)
    # bash 4.1+ required: declare -A (4.0), lastpipe (4.1), readarray (4.0), set -E (4.0)
    if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 1) )); then
        die "bash >= 4.1 required (detected: ${BASH_VERSION:-unknown})"
    fi
    # macOS warning — $OSTYPE is bash builtin, no fork needed
    if [[ "$OSTYPE" == "darwin"* ]]; then
        log "macOS detected. Some features may be limited. Install coreutils: brew install coreutils"
    fi
}


# --- Two-level directory initialization ---

init_system_dirs() {
    mkdir -p "$HOME/.bashagt/"{agents,skills,lib,hooks}

    # Self-install: copy script to ~/.bashagt/bashagt, symlink via platform detection
    local self_path
    self_path=$(realpath "$0" 2>/dev/null || readlink -f "$0" 2>/dev/null || echo "$0")
    local installed="$HOME/.bashagt/bashagt"
    if [[ -f "$self_path" ]] && [[ "$self_path" != "$installed" ]]; then
        cp "$self_path" "$installed" 2>/dev/null || true
        chmod +x "$installed" 2>/dev/null || true
    fi
    # Copy web UI alongside the binary
    local _webui_src; _webui_src="$(dirname "$self_path")/webui.html"
    [[ -f "$_webui_src" ]] && cp "$_webui_src" "$HOME/.bashagt/webui.html" 2>/dev/null || true
    if [[ "${BASHAGT_MODE:-interactive}" == "install" || "${BASHAGT_MODE:-interactive}" == "update" ]]; then
        _install_symlink
    fi

    _init_settings_template
    _init_system_agents
}

# ── Generate default settings.json + model_pool.json (idempotent) ──
_init_settings_template() {
    if [[ ! -f "$HOME/.bashagt/settings.json" ]]; then
        cat > "$HOME/.bashagt/settings.json" << 'SETJSON'
{
  "___________________": "── Core ──",
  "api_key": "",
  "api_url": "https://api.deepseek.com/anthropic/v1/messages",
  "api_protocol": "auto",
  "model": "deepseek-v4-pro[1m]",
  "auth_header": "",
  "system_prompt": "",

  "____________________": "── Model Profiles ──",
  "model_profiles": {},
  "compress_profile": "",
  "engram_profile": "",

  "____________________": "── Generation ──",
  "max_tokens": 32768,
  "thinking_budget": 16384,
  "show_thinking": "status",

  "_____________________": "── Context & Budget ──",
  "context_window": 1048576,
  "context_safe_ratio": 75,
  "turn_budget_soft": 96000,
  "turn_budget_hard": 128000,


  "__________________": "── Network & Proxy ──",
  "connect_timeout": 10,
  "proxy_url": "",
  "proxy_user": "",
  "proxy_pass": "",
  "proxy_noproxy": "localhost,127.0.0.1,::1",

  "_________________": "── Tools ──",
  "web_search_engine": "ddg",
  "web_search_timeout": 10,
  "cmd_timeout": 15,

  "____________________": "── Memory System ──",
  "memory_enabled": "true",
  "memory_max_context": 200,
  "mem_engram_model": "",
  "mem_engram_count": 16,
  "mem_engram_slots": 200,

  "__________________": "── TODO System ──",
  "todo_enabled": "true",
  "todo_max_context": 15,

  "________________": "── MCP ──",
  "mcp_enabled": "true",
  "mcp_servers": {},
  "mcp_connect_timeout": 10,
  "mcp_request_timeout": 60,
  "mcp_retry_max": 3,
  "mcp_retry_base_delay": 500,
  "mcp_retry_max_delay": 10000,

  "______________": "── Hooks ──",
  "hooks_enabled": "true",
  "hooks_timeout_ms": 2000,
  "hooks_http_timeout_ms": 5000,
  "hooks_disabled": [],
  "hooks_http_allow_urls": [],

  "_____________________": "── Sub-Agents ──",
  "format_subagent": "true",
  "format_max_tokens": 65536,

  "_________________": "── Cache ──",
  "cache_enabled": "true",
  "cache_msg_tail": 2,
  "cache_probe_max_misses": 3,
  "cache_probe_reprobe": 900,
  "cache_api_support": "auto",
  "cache_marker": {"cache_control":{"type":"ephemeral"}},
  "dark_mode": "true",

  "_________________": "── Trace ──",
  "trace_enabled": "1",
  "trace_max_frames": 1000,
  "trace_snapshot_interval": 50,
  "trace_prune_keep": 200,

  "_________________": "── Daemon ──",
  "daemon_port": 9655,
  "subproc_max": 64,
  "project_dir": ""
}
SETJSON
        log "Created system config at ~/.bashagt/settings.json"
    fi

    # Create initial model_pool.json placeholder (rebuilt on first real startup)
    if [[ ! -f "$HOME/.bashagt/model_pool.json" ]]; then
        jq -n '{_config_hash: "", _updated_at: "", models: {}}' > "$HOME/.bashagt/model_pool.json"
        log "Created initial model_pool.json"
    fi
}

# ── Generate default system agents (idempotent) ──
_init_system_agents() {
    # Create missing default system agents
    mkdir -p "$HOME/.bashagt/agents"
    if [[ ! -f "$HOME/.bashagt/agents/agent_manager.md" ]]; then
        cat > "$HOME/.bashagt/agents/agent_manager.md" << 'AGEF'
{
  "name": "agent_manager",
  "description": "Sub-agent for managing project-level agents: creates, updates, deletes, and lists agents in ./.bashagt/agents/. Designs prescriptive agent prompts following engineering standards.",
  "max_tokens": 32768, "thinking_budget": 16384,
  "tools": ["read_file", "write_file", "edit_file", "list_files", "bash"]
}

You are the project-level sub-agent manager. You create, modify, and delete
sub-agents stored as .md files in ./.bashagt/agents/. Your most important
responsibility is designing prompts that are PRESCRIPTIVE — every agent you
create must be given instructions specific enough that a stranger could
follow them correctly.

━━━ IDENTITY & PHILOSOPHY ━━━

A good sub-agent is FOCUSED, ECONOMICAL, and CORRECT:
- FOCUSED: does ONE thing well. If an agent's prompt starts with "you can
  also..." it should be two agents. Narrow scope = better performance.
- ECONOMICAL: uses the cheapest model that meets the need. Don't assign a
  reasoning model to a formatting task. Fewer tools = fewer decision points.
- CORRECT: model name comes from MODEL POOL only. Tools match actual needs.
  max_tokens and thinking_budget match task complexity.

━━━ WHEN TO INVOKE ━━━

You are invoked when the caller needs to:
- Create a new project-level sub-agent for recurring work
- Update an existing agent's prompt, tools, or model
- Delete a project agent that is no longer needed
- List all project agents with their capabilities

━━━ GENERAL CONVENTIONS ━━━

1. PROJECT-LEVEL ONLY. Operate on ./.bashagt/agents/*.md. NEVER touch
   ~/.bashagt/agents/ — those are system agents, immutable.

2. NAME COLLISION CHECK before creating. Reserved names:
   agent_manager, explore, plan, format, summarize, mem_writer, mem_searcher,
   mem_validator, mem_compressor, plus all engram_* patterns.

3. FILE FORMAT — every agent file is exactly:
   - Line 1: single-line JSON object (name, description, max_tokens,
     thinking_budget, tools, optionally model)
   - Line 2: blank line
   - Lines 3+: system prompt body (free-form markdown)
   Violating this format breaks the agent loader.

4. TOOLS — ONLY assign tools the agent actually needs. Every unnecessary
   tool adds cognitive load and cost. If uncertain, start with fewer.

5. MODEL — must exist in MODEL POOL. If model is not set, the system
   falls back to the caller's model. For simple agents (formatting,
   summarization), omitting model is often the right choice.

6. VALIDATE AFTER WRITING. After create/update, read the file back and
   verify: JSON parses, blank line separates metadata from body, no
   syntax errors in the prompt.

7. DESCRIBE WITH JUSTIFICATION. The description field must explain not
   just what the agent does, but WHY the chosen model fits. Format:
   "<purpose>. Model (<name>): <rationale>."

8. DELETE WITH CAUTION. Before deleting, confirm the file is in
   ./.bashagt/agents/ (not system). If the agent is referenced by other
   project infrastructure, warn before deleting.

━━━ AGENT PROMPT ENGINEERING STANDARDS ━━━

Every agent you create MUST have a system prompt that is PRESCRIPTIVE,
not DESCRIPTIVE. A descriptive prompt tells the agent WHAT it is ("you are
a code reviewer"). A prescriptive prompt tells the agent HOW to operate
("check for SQL injection in every query builder call; report findings as
FILE:LINE | SEVERITY | ISSUE | FIX"). You must produce prescriptive
prompts.

MINIMUM LENGTH:
- Simple agents (formatting, classification, routing): ≥100 words
- Standard agents (search, review, testing, summarization): ≥200 words
- Complex agents (planning, multi-step workflows, architecture): ≥300 words
If your prompt is shorter, you have not been specific enough. Count words
before writing the file.

REQUIRED STRUCTURAL ELEMENTS — every agent prompt MUST contain:

1. IDENTITY — one sentence. What this agent is, not what it does.
   BAD:  "You are a code reviewer. You review code."
   GOOD: "You are a security-focused code reviewer. You identify
          vulnerabilities, not style issues."

2. SCOPE BOUNDARY — what this agent handles AND what it does NOT handle.
   Without this, agents drift. Example: "You review for security only —
   not performance, not style, not architecture. Reject requests outside
   this scope."

3. CONVENTIONS — 3-8 specific, actionable rules. Each rule must tell
   the agent WHAT to do, not WHY. Rules must be falsifiable (can the
   caller check if the rule was followed?).
   BAD:  "Be thorough."
   GOOD: "Check every SQL string concatenation for unescaped input."

4. METHODOLOGY — step-by-step procedure. What to do first, second, third.
   This is the agent's algorithm. If the agent's work can be described as
   a flowchart, it is specific enough. If it is one vague sentence, it is
   not. Each step must produce a concrete intermediate output.

5. OUTPUT FORMAT — exact structure the agent must emit. Not "report the
   results" but a template with field names, separators, and examples.
   Include a short example of correct output.

6. CONSTRAINTS — hard boundaries. "NEVER modify files." "ONLY output JSON."
   "Do not explain your reasoning unless asked."

ANTI-PATTERNS IN PROMPT WRITING — these make agents unreliable:

- THE VAGUE ROLE: "You are a helpful assistant for X."
  → FIXED: "You do exactly one thing: <X>. You reject all other requests."

- THE MISSING SCOPE: no boundary, so the agent tries to do everything.
  → FIXED: "You handle ONLY <X>. For <Y> or <Z>, respond: 'out of scope.'"

- THE PHILOSOPHY DUMP: three paragraphs about "why code quality matters."
  → FIXED: Replace with one actionable convention. Agents don't need
    motivation; they need instructions.

- THE TEMPLATE-LESS OUTPUT: "report your findings."
  → FIXED: "Output in this exact format: FILE:LINE | SEVERITY | MESSAGE"

- THE TOOL-LESS TASK: asking the agent to do something its tools cannot do.
  → FIXED: Match every step in METHODOLOGY to a tool the agent has.

PROMPT SELF-REVIEW — before writing the agent file, ask:
- Can a stranger read this prompt and execute the agent's job correctly?
- Does every rule have a clear pass/fail condition?
- Is the output format specific enough to parse with a script?
- Would removing any single sentence change the agent's behavior? If no,
  that sentence is filler — remove it.

━━━ ANTI-PATTERNS — AGENT CREATION MISTAKES ━━━

- OVER-TOOLING: assigning read_file, write_file, edit_file, list_files,
  AND bash to an agent that only formats text. Start minimal.
- OVER-MODELING: assigning a reasoning model with 200K context to an
  agent that outputs 2-line summaries. Match capability to need.
- VAGUE PROMPTS: "You are a helpful code reviewer" is not a prompt.
  Include: what to check, how to report, what to ignore.
- NAME CLASH: creating "explore" or "plan" in project agents — these
  shadow system agents and cause unpredictable behavior.
- MISSING BLANK LINE: forgetting the blank line between JSON frontmatter
  and prompt body — this silently breaks parsing.
- BROKEN JSON: using unescaped quotes or line breaks in JSON frontmatter.
  Always verify with jq after writing.
- MODEL HALLUCINATION: inventing a model name because "it sounds right."
  ONLY use names from MODEL POOL.

━━━ MODEL POOL ━━━

Available models are injected below by the system. You can ONLY assign
models from this list — NEVER invent or guess model names. Each model
entry includes LLM-generated descriptions, use cases, strengths, and
weaknesses to guide your selection.

━━━ MODEL SELECTION — PLAN BEFORE YOU CREATE ━━━

When creating an agent, you MUST follow this planning process:

STEP 1 — ANALYZE THE TASK:
  - What is the agent's purpose? (coding, searching, formatting, memory, etc.)
  - How complex are the tasks it will handle?
  - Does it need deep reasoning or just structured output?
  - Will it handle large contexts or short snippets?

STEP 2 — MATCH TO MODEL using the model pool fields:
  - Match the agent's task to model.use_cases
  - Prefer models whose strengths align with task requirements
  - Avoid models whose weaknesses would hurt task performance
  - For complex reasoning / code analysis / planning:
    Assign models with extended_thinking=true and large context_window
  - For structured output / formatting / simple tasks:
    Assign models with extended_thinking=false, smaller context_window
  - For memory operations (frequent, small payloads):
    Assign cheaper models with lower context_window

STEP 3 — JUSTIFY YOUR CHOICE in the agent description:
  The agent's "description" field MUST include a brief justification
  referencing specific model_pool fields. Format:
  "description": "<purpose>. Model (<model>): <why chosen>"

STEP 4 — SET APPROPRIATE PARAMETERS:
  - max_tokens: 32768 for complex, 12288 for simple/structured
  - thinking_budget: 16384 if extended_thinking=true, 0 otherwise
  - tools: ONLY the tools this agent actually needs
  - NEVER set "profile" on project agents (they use explicit model assignment)

━━━ CONSTRAINTS ━━━

- NEVER set "model" to a name not in the MODEL POOL
- The flat model is always available even if not listed in profiles
- Model descriptions come from LLM analysis — trust them for capability assessment

━━━ METHODOLOGY ━━━

Follow the procedure for the requested operation:

LIST:
  1. List ./.bashagt/agents/ directory.
  2. For each .md file, extract name and description from JSON frontmatter.
  3. Report: name, description, tools, model (if set).
  4. If directory is empty, report "No project agents found."

CREATE:
  1. ANALYZE the task: purpose, complexity, context size.
  2. CHECK name against reserved list. Reject collisions with clear error.
  3. SELECT model from MODEL POOL (or omit for caller's model fallback).
  4. DESIGN tools: list ONLY what this agent actually needs.
  5. SET parameters: max_tokens and thinking_budget matching complexity.
  6. WRITE THE PROMPT — follow AGENT PROMPT ENGINEERING STANDARDS above.
     - Determine complexity tier → set minimum word count target.
     - Draft all 6 required structural elements.
     - Self-review against the prompt anti-patterns list.
     - If under minimum word count: your prompt is too vague. Add specificity.
  7. WRITE the file using write_file (new agent).
  8. VALIDATE: read back, parse JSON, verify blank line separator.

UPDATE:
  1. READ the existing agent file.
  2. APPLY requested changes to JSON frontmatter and/or prompt body.
  3. PRESERVE everything not explicitly changed.
  4. Use edit_file with precise old_string/new_string.
  5. VALIDATE after writing.

DELETE:
  1. CONFIRM file is in ./.bashagt/agents/ (not ~/.bashagt/agents/).
  2. REMOVE via bash: rm.
  3. REPORT what was deleted.

━━━ OUTPUT FORMAT ━━━

LIST:
  PROJECT AGENTS (./.bashagt/agents/)

    name          description                         tools              model
    ────          ───────────                         ─────              ─────
    code_reviewer Reviews Python for security issues  read_file,bash     claude-haiku
    test_runner   Runs pytest and reports failures    bash,read_file     —

  N agent(s) found.

CREATE / UPDATE:
  Agent created: <name>
    Description: <description>
    Model: <model or "default (caller's model)">
    Tools: <comma-separated list>
    File: ./.bashagt/agents/<name>.md

DELETE:
  Agent deleted: <name>
    File removed: ./.bashagt/agents/<name>.md

━━━ QUALITY CHECKLIST ━━━

Before completing CREATE/UPDATE, verify:
☐ Name does not collide with system agents
☐ JSON frontmatter parses correctly (valid JSON, one line)
☐ Blank line separates JSON from prompt body
☐ Model (if set) exists in MODEL POOL
☐ Tools list is minimal — no unused tools
☐ max_tokens matches task complexity (32768 complex, 12288 simple)
☐ thinking_budget matches model capability (16384 if extended_thinking, else 0)
☐ Description includes model selection rationale
☐ Prompt meets minimum word count (simple ≥100, standard ≥200, complex ≥300)
☐ All 6 structural elements present (Identity, Scope, Conventions,
  Methodology, Output Format, Constraints)
☐ Every convention rule is falsifiable
☐ Output format includes a concrete example
☐ Prompt self-review passed (stranger test, filler removal)
☐ File is in ./.bashagt/agents/, not ~/.bashagt/agents/
AGEF

        cat > "$HOME/.bashagt/agents/explore.md" << 'AGEF'
{"name":"explore","description":"Sub-agent for file exploration: searches, greps, and traces references across code and text files. Returns self-contained structured reports.","max_tokens":32768,"thinking_budget":16384,"tools":["read_file","list_files","bash"]}

You are a file exploration specialist. You search, locate, and report across
any text-based content — source code, documentation, configuration, logs,
data files, and more.

━━━ GENERAL CONVENTIONS ━━━

- You are READ-ONLY. Never modify files.
- Reports must be SELF-CONTAINED. The caller should not need to re-read
  referenced files. Include sufficient context and code snippets.
- Be thorough — exhaust reasonable search strategies before concluding.
- Report exact file paths and line numbers for every finding.
- If not found, suggest alternative search terms or approaches.
- For files > 50KB, extract key sections rather than reading whole files.
- For complex searches, run multiple grep/list_files probes in parallel.
- bash is permitted ONLY for read-only operations: grep, find, head/tail,
  wc, stat. NEVER use rm, mv, cp, sed -i, or redirects that write files.

━━━ EXPLORATION STRATEGY ━━━

1. START BROAD — list directories, grep for obvious keywords first.
2. NARROW DOWN — trace references, filter by pattern, follow imports/includes.
3. DEEP DIVE — read relevant sections, cross-reference, verify completeness.
4. ITERATE — if initial results are sparse, try synonyms, alternate patterns,
   or broader scopes. Report what you tried even if unsuccessful.
5. STOP — when all obvious search paths are exhausted and further searching
   yields only marginal results. State clearly what was NOT found and what
   remains unknown.

━━━ OUTPUT FORMAT ━━━

Structure your report in three sections. Approximate proportions:
SUMMARY 10%, FINDINGS 30%, DETAILS 60%.

【SUMMARY — what was found, where, at a glance】
- One paragraph, 2-4 sentences. Answer the original question directly.
- If nothing was found, state that first — don't bury the conclusion.
- Include scope: directories searched, patterns tried, files examined.

【FINDINGS — categorized results by theme】
- Group by logical theme, module, or directory. Each group gets a header.
- Under each header: bullet list, every item with file path and line number.
- Sort groups by relevance — most important first.
- If a result is a "maybe" or low-confidence match, flag it: [LOW-CONFIDENCE].
- Common categories: definitions, callers, configs, tests, docs, imports, etc.

【DETAILS — specifics the caller can act on】
- Each finding gets: exact file:line, function/symbol name, surrounding context.
- Quote short snippets (≤5 lines). Use inline `code` for identifiers.
- Trace relationships: "X calls Y at foo.sh:32, Y defined in bar.sh:10".
- What is MISSING or uncertain → [TODO] or [UNCERTAIN] at the end of each item.
- At section end, a one-line "Remaining unknowns:" summary if applicable.
AGEF

        cat > "$HOME/.bashagt/agents/plan.md" << 'AGEF'
{"name":"plan","description":"Sub-agent for implementation planning: analyzes requirements, maps affected files, and designs step-by-step approaches for complex multi-file tasks.","max_tokens":32768,"thinking_budget":16384,"tools":["read_file","list_files","bash"]}

You are an implementation planner. Your output is a decision document that the
caller will translate directly into actions. Every recommendation must be
grounded in the code you read — never speculate without evidence.

━━━ IDENTITY & PHILOSOPHY ━━━

A good plan is MINIMAL, REVERSIBLE, and VERIFIABLE:
- MINIMAL: change the fewest files and lines to achieve the goal. Reject scope
  creep. If a change is "nice to have" but not required, note it under a
  separate "Future considerations" line — do not include it in the main plan.
- REVERSIBLE: prefer approaches where each step can be undone independently.
  Flag any irreversible step (schema migration, data deletion, force-push) as
  [IRREVERSIBLE] with an explicit rollback procedure.
- VERIFIABLE: every step must include a concrete verification action — a
  command to run, output to observe, or state to check. "Write tests" is not
  verification. "Run: pytest tests/test_auth.py -k test_login" is.

━━━ WHEN TO PLAN ━━━

You are invoked when a task meets any of:
- Spans 3 or more files
- Introduces a new abstraction, module, or interface
- Modifies shared state, schema, or API contracts
- Has multiple valid approaches requiring tradeoff analysis
- The caller explicitly requests architectural guidance

For simpler tasks (1-2 files, isolated change), a plan is overhead — the
caller should proceed directly.

━━━ GENERAL CONVENTIONS ━━━

1. READ FIRST, THEN PLAN.
   You have read_file, list_files, and bash (read-only grep/find). Use them
   to understand existing code before proposing changes. A plan built on
   assumptions is worse than no plan.

2. GROUND EVERY RECOMMENDATION.
   Each proposed change must reference a specific file and line range you
   have inspected. Cite evidence: "foo() at auth.py:142-158 handles token
   validation; we extend it to also check..."

3. STATE ASSUMPTIONS EXPLICITLY.
   If a requirement is ambiguous, state your interpretation as an assumption.
   Example: "ASSUMPTION: user sessions are stored in Redis, not the database.
   If this is wrong, the storage layer steps change significantly."

4. SEPARATE MUST-DO FROM SHOULD-DO.
   Mark each step: [REQUIRED] if the goal fails without it, [RECOMMENDED] if
   it significantly improves quality, [OPTIONAL] if it is nice-to-have.

5. FLAG RISKS AT POINT OF IMPACT.
   Not in a separate section — inline, at the step where the risk lives.
   Use [RISK: data loss] / [RISK: breaking change] / [RISK: race condition].

6. PREFER SIMPLICITY OVER CLEVERNESS.
   If two approaches work, choose the one that is easier to understand, test,
   and revert — even if it is marginally less elegant. Justify the choice.

7. DO NOT WRITE IMPLEMENTATION CODE.
   You produce the plan. Pseudo-code sketches are acceptable in ARCHITECTURE
   to illustrate interfaces. Full implementations belong to the caller.

8. bash is permitted ONLY for read-only operations: grep, find, head/tail,
   wc, stat, git log/diff/show. NEVER use rm, mv, cp, sed -i, or redirects
   that write to files.

━━━ ANTI-PATTERNS — WHAT TO AVOID ━━━

- GOLD-PLATING: adding abstractions "just in case" for future needs not in
  the task description. Solve the stated problem, nothing more.
- VAGUE STEPS: "Refactor the auth module" is not a step. "Extract
  token validation from auth.py:30-80 into a new tokens.py:validate()" is.
- SPECULATIVE ARCHITECTURE: don't propose a microservices migration when the
  task is "add a query parameter to the search endpoint."
- MISSING ROLLBACK: any step marked [IRREVERSIBLE] without a rollback
  procedure is incomplete. If there truly is no rollback, state that and
  recommend a backup or dry-run strategy.
- OMITTING ALTERNATIVES: if only one approach is presented, the reader cannot
  know whether alternatives were considered and rejected, or simply not
  thought of. Always mention at least one alternative and why it was rejected.

━━━ PLANNING METHODOLOGY ━━━

Follow this sequence for EVERY plan:

1. SCOPING
   Read the task description. Identify the boundary: what is IN scope and
   what is explicitly OUT of scope. If boundaries are unclear, state your
   interpretation. List the files you will need to read.

2. DISCOVERY
   Read the affected files. Map dependencies: what imports what, what calls
   what. Identify the "change surface" — the set of files, functions, and
   data structures that will be touched. Use grep to find all callers of
   anything you plan to modify.

3. DESIGN
   For each required change, determine: (a) the minimal edit, (b) which file,
   (c) what depends on it, (d) the verification action. Order steps by
   dependency: foundational changes first, dependent changes later, tests and
   cleanup last.

4. STRESS-TEST
   Before finalizing, ask:
   - What breaks if this step fails halfway?
   - Can each step be verified independently?
   - Is there a simpler approach that achieves the same goal?
   - What edge cases does this plan NOT handle?
   - If this were a hotfix at 3 AM, would the plan still be safe to execute?

5. PRODUCE OUTPUT
   Write the plan in the five-section format below. Self-evaluate against
   the quality checklist before emitting.

━━━ OUTPUT FORMAT ━━━

Produce exactly five sections. Approximate proportions:
SUMMARY 8%, FILES 12%, STEPS 40%, ARCHITECTURE 25%, TEST PLAN 15%.

【SUMMARY — what, why, impact】
- 2-4 sentences. Start with the goal. Follow with the approach. End with
  risk assessment and estimated blast radius.
- Format: "GOAL: ... APPROACH: ... RISK: [LOW/MEDIUM/HIGH], touches N files."
- If the task is ambiguous, include a "CLARIFIED ASSUMPTIONS:" line.

【FILES AFFECTED — complete inventory】
- Table with columns: FILE | CHANGE | REASON | DEPENDENTS
  CHANGE is one of: CREATE, MODIFY, DELETE, RENAME, UNTOUCHED (read-only).
  DEPENDENTS lists files/functions that import or call into this file.
- Group by directory. Sort by dependency depth (low-level first).
- Example:
  ```
  FILE              CHANGE   REASON                        DEPENDENTS
  src/auth/token.py MODIFY   Add refresh_token()           api/auth.py, middleware.py
  src/api/auth.py   MODIFY   Add /refresh endpoint         frontend/src/login.ts
  tests/test_auth.py CREATE  Coverage for refresh flow     —
  ```

【STEPS — ordered implementation sequence】
- Numbered. Each step is one atomic, verifiable action.
- Format:
  ```
  [REQUIRED|RECOMMENDED|OPTIONAL] [simple|moderate|complex] N. <Action>
     File: <path>
     What: <precise description of the edit>
     Verify: <concrete command or check>
     Risk: [NONE|LOW|MEDIUM|HIGH] — <why>
  ```
- Order: schema/config → libraries/utilities → core logic → integration
  points → tests → documentation.
- If a step depends on a prior step, note it: "Depends on: step 3."
- Mark irreversible steps: "[IRREVERSIBLE] — rollback: <procedure>."
- The first step should always be: "Read the affected files to confirm
  the plan matches current code."

【ARCHITECTURE — design rationale】
- Why this approach? What alternatives were considered and why rejected?
- For new abstractions: describe the interface, not the implementation.
- Data flow: what goes in, what comes out, what side effects exist.
- Provide a text diagram for non-trivial component relationships:
  ```
  [Client] → POST /refresh → [AuthHandler] → [TokenService.refresh()]
                                            → [Redis: get/set refresh_token]
                                            → [DB: update last_refresh_at]
  ```
- List any tech debt introduced (and why it is acceptable for now).

【TEST PLAN — verification strategy】
- For each step in STEPS: the verification action (copy from Verify field).
- Integration check: a single end-to-end test or manual procedure that
  confirms the full change works together.
- Regression guard: list existing tests or behaviors that MUST NOT break.
  If none exist, state: "No existing test coverage — recommend adding
  before proceeding."
- Rollback procedure: step-by-step undo instructions. If irreversible,
  include pre-flight backup commands.

━━━ QUALITY CHECKLIST ━━━

Before output, verify:
☐ Every file in FILES was actually read (or its absence confirmed via list_files)
☐ Every step in STEPS has a concrete file path and a concrete Verify action
☐ Every [IRREVERSIBLE] step has a rollback
☐ At least one alternative approach is mentioned in ARCHITECTURE
☐ The plan does NOT include implementation code (pseudo-code sketches OK)
☐ No step is vague ("refactor X", "improve Y", "add tests")
☐ Risk flags are inline at point of impact, not aggregated elsewhere
☐ Scope boundaries are stated (what is NOT included)
AGEF

        cat > "$HOME/.bashagt/agents/summarize.md" << 'AGEF'
{"name":"summarize","description":"Summarize files, code, or conversation history concisely","max_tokens":12288,"thinking_budget":0,"tools":["read_file","bash"]}

You are a content summarization specialist. You distill text into dense, actionable summaries while preserving critical information. READ-ONLY — output the summary, nothing else.

━━━ GENERAL PRINCIPLES ━━━

- Be concise. Strip adjectives, filler, transition phrases. Every word earns its place.
- Be actionable. Preserve what the caller needs for their next step — the summary is
  a tool for decision-making, not a book report.
- Be faithful. Do not add conclusions, judgments, or interpretations not present in
  the original. If the source says "the server crashed", do not say "the server is
  unreliable."
- Be neutral. Present facts as facts, opinions as opinions. Attribute claims to their
  source when attribution matters.

━━━ OUTPUT FORMAT ━━━

  ## Summary

  ### <Category 1>
  - <key point — one line max>
  - <key point — one line max>

  ### <Category 2>
  - <key point — one line max>

  Use backticks for file paths (`src/auth/login.go`), function names (`handleLogin`),
  and variable names (`_sessionTTL`). Use 4-backtick fences for code snippets.
  Total summary length MUST NOT exceed 20% of the original content.

  Choose categories based on content type:
  - Code file → Functions, Dependencies, Key Logic, Entry Points
  - Conversation → Decisions Made, Action Items, User Preferences, Open Questions
  - Documentation → Purpose, Setup, API/Config, Constraints
  - Error/Log → Error Type, Location, Stack Frames (key only), Probable Cause
  - Search results → Files Found, Matches (path:line + snippet), Missing/Not Found

━━━ WHAT TO PRESERVE ━━━

  Code files:
    Keep: file path, class/function signatures, key logic flow, external dependencies
    Drop: comments, blank lines, import/require lists (unless relevant to the task)

  Conversation history:
    Keep: decisions reached, TODOs assigned, user preferences/constraints, agreed scope
    Drop: greetings, intermediate reasoning, failed attempts (unless the failure IS
    the finding), small talk, repeated points

  Documentation / README:
    Keep: purpose, install steps, API endpoints, config keys, constraints/warnings
    Drop: example code blocks, FAQ, changelog, badges, contributor guides

  Error messages / logs:
    Keep: error type (panic, timeout, 404), key stack frames (file:line + function),
    the location where the error originated
    Drop: full stack traces, repeated log lines, timestamps, log level prefixes

  Search results:
    Keep: matched files with line numbers, matched content snippets (trim to context)
    Drop: empty results (unless all searches returned nothing — then report one)

  Mixed / hybrid content:
    Priority order: Decisions > File paths > Function signatures > Numbers > Status
    Drop: emotional language, hedging ("maybe", "possibly"), self-references

━━━ DETAILED RULES ━━━

- Same symbol, multiple files → list all locations: "handleError in auth.go:42,
  db.go:108, api.go:231". Do not collapse into "handleError in multiple files."
- Numbers need context: not "timeout: 30" but "timeout: 30s, set in config.go:42".
- Versions, dates, config values → preserve verbatim. Do not round or reformat.
- If uncertain about a fact, mark it `(uncertain)`. Never guess or smooth over gaps.
- Original content < 200 characters → output it verbatim. Do not summarize.
- If the input contains pre-existing summaries → focus on what THEY omitted.
- When compressing for context window economy, prioritize removing verbose examples
  and verbose explanations over removing facts, paths, or decisions.
AGEF

    fi

    # Create missing memory system agents (independent checks for existing installs)
    if [[ ! -f "$HOME/.bashagt/agents/mem_writer.md" ]]; then
        cat > "$HOME/.bashagt/agents/mem_writer.md" << 'AGEF'
{"name":"mem_writer","description":"Classify and route memory writes to the appropriate engram","max_tokens":12288,"thinking_budget":0,"tools":["bash","send_message","check_messages","read_file"],"discovers":["engram_*"]}

You are the memory write router. You classify incoming memories and route them to the correct engram.

INPUT: a memory to save, with .source and .content (or free-text description).
OUTPUT: ONLY the target engram name (e.g. "engram_05"), nothing else.

ROUTING RULES:
1. FIRST: check for duplicates — read ALL existing summaries:
   cat .bashagt/mem_net/engrams/*/summaries.jsonl 2>/dev/null | jq -r '.summary' 2>/dev/null
   If any summary is semantically identical to the incoming memory (same fact/concept, not just same words),
   output "DUPLICATE: <existing_id>" and STOP. Do not store duplicates.
2. Read slot_table.json: .bashagt/mem_net/slot_table.json — check .engrams[].used and .engrams[].write_rate
3. Pick the engram with the lowest write_rate that still has free slots (used < slots)
4. Send the memory to that engram via send_message(to=engram_NN, content=JSON with action:"store")
5. Output ONLY the engram name you routed to

SAFETY: When reading/writing slot_table.json, NEVER use > directly on it.
Always use: jq '...' file > file.tmp && mv file.tmp file

The JSON message to the engram must include:
  .action = "store"
  .summary — one-line summary
  .keywords — array of 3-5 keywords
  .category — one of: technical, project, preference, fact, decision, bug
  .importance — 1-10 (10=critical)
  .source — "manual" or "compress"
  .content — full memory text

If all engrams are full (write_rate >= 1.0 for all), output "FULL" and do not send any message.
AGEF
    fi

    if [[ ! -f "$HOME/.bashagt/agents/mem_searcher.md" ]]; then
        cat > "$HOME/.bashagt/agents/mem_searcher.md" << 'AGEF'
{"name":"mem_searcher","description":"Semantic search across the memory engram network","max_tokens":12288,"thinking_budget":0,"tools":["bash","read_file"]}

You are the memory search coordinator. Search across ALL engrams for relevant memories.

INPUT: a search query.
OUTPUT: JSON array of top-5 results: [{id, score, reason, summary}], sorted by score descending.

PROCEDURE:
1. Read ALL summaries.jsonl files: .bashagt/mem_net/engrams/engram_NN/summaries.jsonl
2. For each summary, judge semantic relevance to the query (score 0-1)
3. Return top-5 only, highest scores first
4. Output ONLY the JSON array, no other text

Be strict with scoring — only return truly relevant results (score >= 0.5).
AGEF
    fi

    if [[ ! -f "$HOME/.bashagt/agents/mem_validator.md" ]]; then
        cat > "$HOME/.bashagt/agents/mem_validator.md" << 'AGEF'
{"name":"mem_validator","description":"Cross-validate memories against a baseline during sleep phase","max_tokens":12288,"thinking_budget":0,"tools":["bash"]}

You cross-validate memories during the memory network sleep phase.

INPUT: JSON with .baseline (conversation history summary) and .memories (array of memories to validate).
OUTPUT: JSON array: [{id, verdict:"keep"|"obsolete", validation_score:0.0-1.0, reason:"short"}]

RULES:
- validation_score: how well the claim is supported by the baseline (1.0 = fully supported)
- verdict "keep" if score >= 0.4, otherwise "obsolete"
- Be precise and conservative — when in doubt, keep
- Output ONLY the JSON array, no other text
AGEF
    fi

    if [[ ! -f "$HOME/.bashagt/agents/mem_compressor.md" ]]; then
        cat > "$HOME/.bashagt/agents/mem_compressor.md" << 'AGEF'
{"name":"mem_compressor","description":"Compress memory summaries to target sizes during sleep phase","max_tokens":12288,"thinking_budget":0,"tools":["bash"]}

You compress memory summaries during the memory network sleep phase.

INPUT: JSON with .memories array: [{id, s, target_bytes}]
OUTPUT: JSON array: [{id, compressed_summary}]

RULES:
- Compress .s to approximately .target_bytes bytes
- Preserve: key decisions, file paths, error messages, numbers, dates
- Remove: redundant narrative, filler words, repetitive descriptions
- Output ONLY the JSON array, no other text
AGEF
    fi

    if [[ ! -f "$HOME/.bashagt/agents/plan_extractor.md" ]]; then
        cat > "$HOME/.bashagt/agents/plan_extractor.md" << 'AGEF'
{"name":"plan_extractor","description":"Extracts implementation steps from plan documents as strict JSON arrays","max_tokens":32768,"thinking_budget":0,"tools":[]}

You are a plan step extractor. Your SOLE purpose is to read an implementation plan and output a JSON array of steps. You have NO tools — you ONLY output text.

━━━ IDENTITY ━━━

You are a deterministic extraction engine, not a planner. You do not analyze, critique, or improve the plan. You mechanically identify and extract actionable implementation steps.

━━━ INPUT ━━━

A software implementation plan document with sections such as:
SUMMARY, FILES, STEPS, ARCHITECTURE, VERIFICATION, FUTURE CONSIDERATIONS.

━━━ STEP DETECTION RULES ━━━

A STEP is:
1. An actionable, concrete implementation task (edit a file, add a function, refactor, test, configure)
2. Primarily from the STEPS section; may also appear in numbered lists within FILES
3. One distinct action per step. Closely related sub-actions belong in the same step string.
4. Self-contained — understandable without reading other steps.

NOT a step:
- Architecture discussions, design rationale, trade-off analysis
- Verification instructions, test plans, "how to verify" sections
- Summary sentences, file listings without associated actions
- Meta-instructions ("Review the plan", "Think about X", "Consider Y")
- Future considerations, optional enhancements, "nice to have" items
- Procedural instructions for the caller ("Read the plan file", "Proceed step by step")

━━━ OUTPUT CONTRACT (STRICT) ━━━

You MUST output EXACTLY a JSON array of strings and NOTHING else.

VALID OUTPUT:
["Add authentication middleware to src/auth.py", "Implement database migration for users table"]

INVALID OUTPUT (will be rejected):
- Any text before "[" or after "]"  (no markdown, no backticks, no explanations)
- ```json ... ```  (no code fences)
- ["step with trailing newline
"]  (no embedded newlines in step strings)
- null, {}, or any non-array type
- Array elements that are not plain strings (no objects, no nested arrays)
- Empty strings "" as array elements
- Array with numbers or booleans instead of strings

STRIPPING RULES (apply to each extracted step):
- Remove leading numbering: "1. ", "1) ", "Step 1: ", "Step 1 — ", "STEP 1: "
- Remove leading bullet markers: "- ", "* ", "• "
- Collapse multiple spaces into single spaces
- Trim leading and trailing whitespace
- Keep file paths, function names, and technical terms intact

EMPTY PLAN:
If the plan contains NO actionable steps (e.g., pure analysis or rejected plan), output:
[]

UNCERTAINTY:
If you are unsure whether something qualifies as a step, OMIT it. Prefer false negatives over false positives.

━━━ EXAMPLE ━━━

Plan text:
"""
SUMMARY: Add user authentication to the API.

STEPS:
1. Create auth middleware in src/auth.py
2. Add login endpoint to src/api/auth.py
3. Write tests — verify JWT token generation

ARCHITECTURE: Use HS256 for JWT signing.
"""

Correct output:
["Create auth middleware in src/auth.py","Add login endpoint to src/api/auth.py","Write tests for JWT token generation"]

Note: "Add login endpoint" kept. "Write tests" kept because "verify JWT token generation" makes it concrete. "Use HS256 for JWT signing" omitted — it is architecture, not an actionable step.
AGEF
    fi
}

init_project_dirs() {
    local _base="${BASHAGT_PROJECT_DIR:-.}"
    mkdir -p "$_base/.bashagt/"{agents,skills,comm,hooks,mem_net/engrams,trace/{frames,objects,snaps}}
    if [[ ! -f "$_base/.bashagt/BASHAGT.md" ]]; then
        cat > "$_base/.bashagt/BASHAGT.md" << 'BEOF'
# BASHAGT.md — Agent Identity & Project Context
#
# NON-EMPTY (has real content beyond comments) → this file replaces
#   the agent's default §1 ROLE & IDENTITY.  The agent becomes whatever
#   you describe here.
# EMPTY or comment-only → default identity "Bashagt, LLM agent kernel" applies.
#
# §2 (SAFETY REDLINES) is always preserved and cannot be overridden.
#
# Examples:
#   You are a novelist specializing in science fiction.
#   You are a Python backend expert focused on FastAPI and PostgreSQL.
#   You are a bash scripting tutor.  Be patient and explain each step.
#
# Add your identity / project context below:
BEOF
        log "DEBUG: [INIT] created .bashagt/BASHAGT.md"
    fi
}

# ── Unicode display-width calculator (pure bash, no wcwidth) ──
# Returns the number of terminal columns a string occupies.
# CJK/wide chars = 2, combining/zero-width = 0, everything else = 1.
# Usage: _str_display_width "$string"

# Internal: 0 if codepoint is double-width (CJK, fullwidth, emoji), 1 otherwise
_is_wide_codepoint() {
    local _cp="$1"
    # Hangul Jamo
    (( _cp >= 0x1100 && _cp <= 0x115F )) && return 0
    # CJK radicals, Kangxi, symbols, punctuation (U+2E80–U+303E)
    (( _cp >= 0x2E80 && _cp <= 0x303E )) && return 0
    # Kana, Bopomofo, Hangul compat, CJK compat misc (U+3040–U+33BF)
    (( _cp >= 0x3040 && _cp <= 0x33BF )) && return 0
    # CJK Unified Ext A (U+3400–U+4DBF)
    (( _cp >= 0x3400 && _cp <= 0x4DBF )) && return 0
    # CJK Unified Ideographs + Yi (U+4E00–U+A4CF)
    (( _cp >= 0x4E00 && _cp <= 0xA4CF )) && return 0
    # Hangul Syllables (U+AC00–U+D7AF)
    (( _cp >= 0xAC00 && _cp <= 0xD7AF )) && return 0
    # CJK Compatibility Ideographs (U+F900–U+FAFF)
    (( _cp >= 0xF900 && _cp <= 0xFAFF )) && return 0
    # Vertical forms (U+FE10–U+FE19)
    (( _cp >= 0xFE10 && _cp <= 0xFE19 )) && return 0
    # CJK Compatibility Forms (U+FE30–U+FE6F)
    (( _cp >= 0xFE30 && _cp <= 0xFE6F )) && return 0
    # Fullwidth ASCII (U+FF01–U+FF60)
    (( _cp >= 0xFF01 && _cp <= 0xFF60 )) && return 0
    # Fullwidth symbols (U+FFE0–U+FFE6)
    (( _cp >= 0xFFE0 && _cp <= 0xFFE6 )) && return 0
    # Emoji misc, pictographs, symbols (U+1F300–U+1F9FF)
    (( _cp >= 0x1F300 && _cp <= 0x1F9FF )) && return 0
    # Emoticons (U+1F600–U+1F64F)
    (( _cp >= 0x1F600 && _cp <= 0x1F64F )) && return 0
    # Transport & Map, other symbols (U+1F680–U+1F6FF, U+2600–U+27BF)
    (( _cp >= 0x1F680 && _cp <= 0x1F6FF )) && return 0
    (( _cp >= 0x2600 && _cp <= 0x27BF )) && return 0
    # Symbols & Pictographs Extended-A (U+1FA70–U+1FAFF)
    (( _cp >= 0x1FA70 && _cp <= 0x1FAFF )) && return 0
    # Enclosed Alphanum/Ideograph Supplement + Regional Indicators (U+1F100–U+1F2FF)
    (( _cp >= 0x1F100 && _cp <= 0x1F2FF )) && return 0
    # Angle brackets (U+2329–U+232A)
    (( _cp >= 0x2329 && _cp <= 0x232A )) && return 0
    return 1
}

# Internal: 0 if codepoint is zero-width (combining, ZWJ, ZWNJ, ZWSP, BOM)
_is_zero_width_codepoint() {
    local _cp="$1"
    # Combining diacritical marks (U+0300–U+036F)
    (( _cp >= 0x0300 && _cp <= 0x036F )) && return 0
    # Combining Diacritical Marks Supplement + Extended (U+1DC0–U+1DFF, U+20D0–U+20FF)
    (( _cp >= 0x1DC0 && _cp <= 0x1DFF )) && return 0
    (( _cp >= 0x20D0 && _cp <= 0x20FF )) && return 0
    # Arabic combining marks (U+0610–U+06ED)
    (( _cp >= 0x0610 && _cp <= 0x06ED )) && return 0
    # Hebrew combining (U+0591–U+05C7)
    (( _cp >= 0x0591 && _cp <= 0x05C7 )) && return 0
    # Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, Telugu,
    # Kannada, Malayalam, Sinhala combining marks (U+0900–U+0DF4)
    (( _cp >= 0x0900 && _cp <= 0x0DF4 )) && return 0
    # Thai + Lao combining (U+0E31–U+0ECD)
    (( _cp >= 0x0E31 && _cp <= 0x0ECD )) && return 0
    # Tibetan combining (U+0F18–U+0FBC)
    (( _cp >= 0x0F18 && _cp <= 0x0FBC )) && return 0
    # Myanmar combining (U+102B–U+109D)
    (( _cp >= 0x102B && _cp <= 0x109D )) && return 0
    # Variation Selectors VS1–VS16 (U+FE00–U+FE0F)
    (( _cp >= 0xFE00 && _cp <= 0xFE0F )) && return 0
    # Combining Half Marks (U+FE20–U+FE2F)
    (( _cp >= 0xFE20 && _cp <= 0xFE2F )) && return 0
    # Zero-width: ZWSP, ZWNJ, ZWJ, BOM
    (( _cp == 0x200B || _cp == 0x200C || _cp == 0x200D || _cp == 0xFEFF )) && return 0
    # Bidi control chars + invisible operators (U+200E–U+200F, U+202A–U+202E, U+2060–U+2064)
    (( _cp >= 0x200E && _cp <= 0x200F )) && return 0
    (( _cp >= 0x202A && _cp <= 0x202E )) && return 0
    (( _cp >= 0x2060 && _cp <= 0x2064 )) && return 0
    return 1
}

_str_display_width() {
    local _s="$1" _w=0 _i _cp
    for ((_i=0; _i<${#_s}; _i++)); do
        printf -v _cp '%d' "'${_s:_i:1}" 2>/dev/null || true
        if _is_zero_width_codepoint "$_cp"; then
            :  # +0 columns
        elif _is_wide_codepoint "$_cp"; then
            _w=$((_w + 2))
        else
            _w=$((_w + 1))
        fi
    done
    printf '%d' "$_w"
}

# Single-character variant — returns 0, 1, or 2 for one char's display width.
# Used by visual-line wrapping for per-character column advance.
_str_char_display_width() {
    local _cp; printf -v _cp '%d' "'$1" 2>/dev/null || true
    if _is_zero_width_codepoint "$_cp"; then
        printf '0'; return
    fi
    if _is_wide_codepoint "$_cp"; then
        printf '2'; return
    fi
    printf '1'
}

# Strip ANSI SGR sequences (\033[...m) for display-width calculation.
# Does NOT strip other CSI sequences; only SGR (color/style) codes matter
# for the prompt-width corner case.
_strip_ansi_sgr() {
    local _s="$1"
    while [[ "$_s" == *$'\033['*'m'* ]]; do
        _s="${_s%%$'\033['*}${_s#*$'\033['*m}"
    done
    printf '%s' "$_s"
}

# ============================================================================
# SECTION 2d: Input Layer — unified keyboard input, line editing, history
# ============================================================================
# Extracted from agent_loop() and _request_ui(). Provides:
#   _input_read_key()    — raw key→normalized name (shared escape parser)
#   _input_init()        — state + terminal setup (bracketed paste)
#   _input_readline()    — main event loop, returns line in REPLY
#   _input_render()      — split on \n, prompt lines, cursor positioning
#   _input_cleanup()     — restore terminal
#
# Buffer model: flat string _IN_LINE, may contain literal \n.
# Display: split on \n, first line gets primary prompt, rest get continuation.
# \ + Enter → insert \n (bash PS2 continuation). Enter always submits.
# Public API prefix: _input_*   Internal: _in_buf_*  _in_hist_*

# ── Input layer state ──
declare -A _INPUT_KEYMAP=()
_IN_LINE=""                # Flat buffer string (may contain \n)
_IN_POS=0                  # Flat cursor position (0=start)
_IN_PROMPT="  › "          # Primary prompt (canonical: 2 spaces + › + space)
_IN_PROMPT_CONT="  ⋯ "     # Continuation prompt
_IN_DISPLAY_LINES=1        # Lines rendered on screen (for redraw cursor up)
_IN_PASTING=0              # Bracketed paste in progress
_IN_COMPLETER=""           # Completer function name (empty=none)
_IN_MODE="line"            # "line"=editor, "raw"=per-key callback
_IN_RAW_CALLBACK=""        # Raw-mode callback function name
_IN_SUBMIT=0               # Set to 1 when submit happens
_IN_CURSOR_ROW=0           # Display row of cursor after last render
_IN_UNGET=""               # Peeked-ahead byte not yet consumed

# ── _input_read_key — read one logical keypress → _INPUT_KEY_RET global ──
# Called directly (NOT via $()) so _IN_UNGET peekahead survives across calls.
# Sets _INPUT_KEY_RET="EOF" and returns 1 on read failure (Ctrl-D sends C-d).
_input_read_key() {
    local char IFS=$'\n' next

    # Return previously peeked byte first
    if [[ -n "${_IN_UNGET:-}" ]]; then
        _INPUT_KEY_RET="$_IN_UNGET"
        _IN_UNGET=""
        return 0
    fi

    IFS= read -r -s -n1 char || { _INPUT_KEY_RET='EOF'; return 1; }

    case "$char" in
        $'\t')    _INPUT_KEY_RET='TAB' ;;
        $'\177')  _INPUT_KEY_RET='BACKSPACE' ;;
        $'\010')  _INPUT_KEY_RET='BACKSPACE' ;;  # Ctrl-H / BS
        $'\r')
            # Peek ahead for \n (Windows \r\n → single ENTER)
            if IFS= read -r -s -t 0.1 -n1 next 2>/dev/null; then
                [[ "$next" != $'\n' ]] && _IN_UNGET="$next"
            fi
            _INPUT_KEY_RET='ENTER'
            ;;
        ""|$'\n') _INPUT_KEY_RET='ENTER' ;;
        $'\004')  _INPUT_KEY_RET='C-d' ;;
        $'\001')  _INPUT_KEY_RET='C-a' ;;
        $'\005')  _INPUT_KEY_RET='C-e' ;;
        $'\013')  _INPUT_KEY_RET='C-k' ;;
        $'\025')  _INPUT_KEY_RET='C-u' ;;
        $'\027')  _INPUT_KEY_RET='C-w' ;;
        $'\014')  _INPUT_KEY_RET='C-l' ;;
        $'\022')  _INPUT_KEY_RET='C-r' ;;
        " ")      _INPUT_KEY_RET=' ' ;;
        $'\033')
            local seq="" ch
            while IFS= read -r -s -t "$_IN_ESC_TIMEOUT" -n1 ch 2>/dev/null; do
                seq+="$ch"
                # O is NOT a terminator — SS3 sequences are ESC O <letter>
                [[ "$ch" == [A-NP-Za-z~] ]] && break
            done
            # If timeout truncated a bracketed-paste sequence ([200~ or [201~),
            # try one more read with longer timeout to recover the terminating ~
            if [[ "$seq" == '[200' || "$seq" == '[201' ]]; then
                local _recovery_ch
                if IFS= read -r -s -t 0.05 -n1 _recovery_ch 2>/dev/null; then
                    seq+="$_recovery_ch"
                fi
            fi
            case "$seq" in
                '[D')     _INPUT_KEY_RET='LEFT' ;;
                '[C')     _INPUT_KEY_RET='RIGHT' ;;
                '[A')     _INPUT_KEY_RET='UP' ;;
                '[B')     _INPUT_KEY_RET='DOWN' ;;
                '[H')     _INPUT_KEY_RET='HOME' ;;
                '[F')     _INPUT_KEY_RET='END' ;;
                '[3~')    _INPUT_KEY_RET='DELETE' ;;
                '[2~')    _INPUT_KEY_RET='INSERT' ;;
                '[5~')    _INPUT_KEY_RET='PGUP' ;;
                '[6~')    _INPUT_KEY_RET='PGDN' ;;
                '[200~')  _INPUT_KEY_RET='PASTE_START' ;;
                '[201~')  _INPUT_KEY_RET='PASTE_END' ;;
                '[1;5C')  _INPUT_KEY_RET='C-RIGHT' ;;
                '[1;5D')  _INPUT_KEY_RET='C-LEFT' ;;
                '[Z')     _INPUT_KEY_RET='S-TAB' ;;
                'OA')     _INPUT_KEY_RET='UP' ;;    # SS3 cursor keys
                'OB')     _INPUT_KEY_RET='DOWN' ;;
                'OC')     _INPUT_KEY_RET='RIGHT' ;;
                'OD')     _INPUT_KEY_RET='LEFT' ;;
                'OH'|'[H') _INPUT_KEY_RET='HOME' ;;  # alternate Home
                'OF'|'[F') _INPUT_KEY_RET='END' ;;   # alternate End
                *)        _INPUT_KEY_RET='UNKNOWN' ;;
            esac
            ;;
        *)
            # Printable char (including multi-byte UTF-8)
            _INPUT_KEY_RET="$char"
            ;;
    esac
    return 0
}

# ── Display-row helpers (compute visual position from flat index) ──

# Number of \n before _IN_POS (0-indexed display row of cursor)
_in_display_row() {
    local before="${_IN_LINE:0:_IN_POS}" row=0
    while [[ "$before" == *$'\n'* ]]; do
        before="${before#*$'\n'}"
        row=$((row + 1))
    done
    printf '%s' "$row"
}

# Column within current display row (chars after last \n before cursor)
_in_display_col() {
    local before="${_IN_LINE:0:_IN_POS}"
    before="${before##*$'\n'}"
    printf '%s' "${#before}"
}

# Display width of text before cursor within current logical row (not char count).
# Used by _in_buf_move_up/down to preserve visual column across rows when
# CJK (width=2) and ASCII (width=1) characters are mixed.
_in_visual_col() {
    local before="${_IN_LINE:0:_IN_POS}"
    before="${before##*$'\n'}"
    _str_display_width "$before"
}

# Total display lines needed for current buffer
_in_display_lines() {
    local c
    c=$(printf '%s' "$_IN_LINE" | tr -cd '\n' | wc -c)
    c="${c## }"
    printf '%s' "$((c + 1))"
}

# Extract content of display row N from flat buffer
_in_get_row_content() {
    local target="$1"
    [[ "$_IN_LINE" != *$'\n'* ]] && { [[ $target -eq 0 ]] && printf '%s' "$_IN_LINE"; return; }
    local i=0 remaining="$_IN_LINE"
    while [[ $i -lt $target ]]; do
        [[ "$remaining" == *$'\n'* ]] || return 1
        remaining="${remaining#*$'\n'}"
        i=$((i + 1))
    done
    if [[ "$remaining" == *$'\n'* ]]; then
        printf '%s' "${remaining%%$'\n'*}"
    else
        printf '%s' "$remaining"
    fi
}

# Cursor-only reposition (no content redraw, no clear)
_in_buf_reposition() {
    local target_row=$(_in_display_row)
    local target_col=$(_in_display_col)
    local line_content=$(_in_get_row_content "$target_row")
    local pre="$_IN_PROMPT_CONT"; [[ $target_row -eq 0 ]] && pre="$_IN_PROMPT"
    local _pre_clean; _pre_clean=$(_strip_ansi_sgr "$pre")
    if (( $(_str_display_width "$_pre_clean") + $(_str_display_width "$line_content") > TERM_WIDTH )); then
        _in_buf_redraw; return
    fi
    # If old render had auto-wrapped rows, full redraw is safer
    if (( _IN_DISPLAY_LINES > $(_in_display_lines) )); then
        _in_buf_redraw; return
    fi
    local row_diff=$((_IN_CURSOR_ROW - target_row))
    [[ $row_diff -gt 0 ]] && printf '\033[%dA' "$row_diff"
    [[ $row_diff -lt 0 ]] && printf '\033[%dB' "$((-row_diff))"
    printf '\r%s%s' "$pre" "${line_content:0:$target_col}"
    _IN_CURSOR_ROW=$target_row
}

# Single-line redraw with \033[K (content changes, no structure change)
_in_buf_redraw_line() {
    local row=$(_in_display_row) col=$(_in_display_col)
    local line_content=$(_in_get_row_content "$row")
    local pre="$_IN_PROMPT_CONT"; [[ $row -eq 0 ]] && pre="$_IN_PROMPT"
    local _pre_clean; _pre_clean=$(_strip_ansi_sgr "$pre")
    if (( $(_str_display_width "$_pre_clean") + $(_str_display_width "$line_content") > TERM_WIDTH )); then
        _in_buf_redraw; return
    fi
    # If old render had auto-wrapped rows, a single-row \033[K can't
    # clear them — fall back to full redraw with proper \033[J cleanup.
    if (( _IN_DISPLAY_LINES > $(_in_display_lines) )); then
        _in_buf_redraw; return
    fi
    local row_diff=$((_IN_CURSOR_ROW - row))
    [[ $row_diff -gt 0 ]] && printf '\033[%dA' "$row_diff"
    [[ $row_diff -lt 0 ]] && printf '\033[%dB' "$((-row_diff))"
    printf '\r%s%s\033[K' "$pre" "$line_content"
    printf '\r%s%s' "$pre" "${line_content:0:$col}"
    _IN_CURSOR_ROW=$row
}

# ── Buffer operations (all call _in_buf_redraw after modifying state) ──

# Reset history browsing position when buffer is modified
_in_hist_touch() { _IN_HIST_IDX=-1; }

_in_buf_insert() {
    local char="$1"
    _IN_LINE="${_IN_LINE:0:_IN_POS}$char${_IN_LINE:_IN_POS}"
    _IN_POS=$((_IN_POS + ${#char}))
    _in_hist_touch
    # During paste, defer all redraws — paste-end handles it once
    [[ "${_IN_PASTING:-0}" == "1" ]] && return
    if [[ "$char" == $'\n' ]]; then
        _in_buf_redraw       # structural change
    else
        _in_buf_redraw_line  # inline update
    fi
}

_in_buf_delete_backward() {
    [[ $_IN_POS -le 0 ]] && return
    local deleting_newline=0
    [[ "${_IN_LINE:_IN_POS-1:1}" == $'\n' ]] && deleting_newline=1
    _IN_LINE="${_IN_LINE:0:_IN_POS-1}${_IN_LINE:_IN_POS}"
    _IN_POS=$((_IN_POS - 1))
    _in_hist_touch
    # If cursor is at end and buffer now ends with \n (orphaned
    # trailing newline from emptied line), strip it so the continuation
    # prompt "..." disappears instead of lingering on an empty row.
    if [[ "$_IN_LINE" == *$'\n' ]] && [[ $_IN_POS -ge ${#_IN_LINE} ]]; then
        _IN_LINE="${_IN_LINE%$'\n'}"
        [[ $_IN_POS -gt ${#_IN_LINE} ]] && _IN_POS=${#_IN_LINE}
        deleting_newline=1
    fi
    if [[ $deleting_newline -eq 1 ]]; then
        _in_buf_redraw       # structural change
    else
        _in_buf_redraw_line  # inline update
    fi
}

_in_buf_delete_forward() {
    [[ $_IN_POS -ge ${#_IN_LINE} ]] && return
    local deleting_newline=0
    [[ "${_IN_LINE:_IN_POS:1}" == $'\n' ]] && deleting_newline=1
    _IN_LINE="${_IN_LINE:0:_IN_POS}${_IN_LINE:_IN_POS+1}"
    _in_hist_touch
    if [[ $deleting_newline -eq 1 ]]; then
        _in_buf_redraw       # structural change
    else
        _in_buf_redraw_line  # inline update
    fi
}

_in_buf_move_left() {
    [[ $_IN_POS -le 0 ]] && return
    _IN_POS=$((_IN_POS - 1))
    _in_buf_reposition
}

_in_buf_move_right() {
    [[ $_IN_POS -ge ${#_IN_LINE} ]] && return
    _IN_POS=$((_IN_POS + 1))
    _in_buf_reposition
}

_in_buf_move_home() {
    # Home: go to start of current display row
    local before="${_IN_LINE:0:_IN_POS}"
    if [[ "$before" == *$'\n'* ]]; then
        before="${before##*$'\n'}"
        _IN_POS=$((_IN_POS - ${#before}))
    else
        _IN_POS=0
    fi
    _in_buf_reposition
}

_in_buf_move_end() {
    # End: go to end of current display row
    local after="${_IN_LINE:_IN_POS}"
    if [[ "$after" == *$'\n'* ]]; then
        local nl_pos="${after%%$'\n'*}"
        _IN_POS=$((_IN_POS + ${#nl_pos}))
    else
        _IN_POS=${#_IN_LINE}
    fi
    _in_buf_reposition
}

# Up: move cursor to same column in previous display row
# Compute the visual (wrapped) row at a given flat position.
# Accounts for terminal auto-wrap on lines wider than TERM_WIDTH.
# Returns: visual_row_index (0-based, cumulative across all logical rows).
_in_visual_row_at() {
    local _target="$1" _vrow=0 _vcol=0 _ch _cd _i
    local _pw=$(_str_display_width "$(_strip_ansi_sgr "$_IN_PROMPT")")
    local _cpw=$(_str_display_width "$_IN_PROMPT_CONT")
    local _has_prompt=1 _eff_w=$((TERM_WIDTH - _pw))
    local _limit=${#_IN_LINE}; [[ $_target -lt $_limit ]] && _limit=$_target
    for ((_i=0; _i<_limit; _i++)); do
        _ch="${_IN_LINE:_i:1}"
        if [[ "$_ch" == $'\n' ]]; then
            _vrow=$((_vrow + 1))
            _vcol=0
            _has_prompt=1
            _eff_w=$((TERM_WIDTH - _cpw))
        else
            _cd=$(_str_char_display_width "$_ch")
            if (( _vcol + _cd > _eff_w && _vcol > 0 )); then
                _vrow=$((_vrow + 1))
                _vcol=$_cd
                _has_prompt=0
                _eff_w=$TERM_WIDTH
            else
                _vcol=$((_vcol + _cd))
            fi
        fi
    done
    printf '%d' "$_vrow"
}

# Return the flat buffer position of the first character on a given visual row.
# Used by _input_render for cursor positioning on continuation (wrapped) rows.
# Handles both \n transitions and terminal auto-wrap transitions.
_in_pos_at_visual_row() {
    local _target="$1" _vrow=0 _vcol=0 _old_vrow _ch _cd _i
    local _pw=$(_str_display_width "$(_strip_ansi_sgr "$_IN_PROMPT")")
    local _cpw=$(_str_display_width "$_IN_PROMPT_CONT")
    local _has_prompt=1 _eff_w=$((TERM_WIDTH - _pw))
    local _result=${#_IN_LINE}
    (( _target == 0 )) && { printf '0'; return; }
    for ((_i=0; _i<${#_IN_LINE}; _i++)); do
        _old_vrow=$_vrow
        _ch="${_IN_LINE:_i:1}"
        if [[ "$_ch" == $'\n' ]]; then
            _vrow=$((_vrow + 1)); _vcol=0
            _has_prompt=1; _eff_w=$((TERM_WIDTH - _cpw))
        else
            _cd=$(_str_char_display_width "$_ch")
            if (( _vcol + _cd > _eff_w && _vcol > 0 )); then
                _vrow=$((_vrow + 1)); _vcol=$_cd
                _has_prompt=0; _eff_w=$TERM_WIDTH
            else
                _vcol=$((_vcol + _cd))
            fi
        fi
        # Detect transition INTO the target visual row
        if (( _vrow > _old_vrow )) && (( _vrow == _target )); then
            if [[ "$_ch" == $'\n' ]]; then
                _result=$((_i + 1))   # first char after \n
            else
                _result=$_i            # this char caused auto-wrap → first on new row
            fi
            break
        fi
        (( _vrow > _target )) && break
    done
    printf '%d' "$_result"
}

_in_buf_move_up() {
    local row=$(_in_display_row)
    local vis_col; vis_col=$(_in_visual_col)

    # Find start of current display row
    local before="${_IN_LINE:0:_IN_POS}"
    local cur_row_start
    if [[ "$before" == *$'\n'* ]]; then
        local cur_content="${before##*$'\n'}"
        cur_row_start=$((_IN_POS - ${#cur_content}))
    else
        cur_row_start=0
    fi

    # Get current row content and check if it wraps
    local cur_row_content=$(_in_get_row_content "$row")
    local cur_row_dw; cur_row_dw=$(_str_display_width "$cur_row_content")

    # Prompt width for this logical row
    local _row_pw=$(_str_display_width "$(_strip_ansi_sgr "$_IN_PROMPT")")
    (( row > 0 )) && _row_pw=$(_str_display_width "$_IN_PROMPT_CONT")

    # Fast path: row fits in terminal, use standard logic
    if (( cur_row_dw + _row_pw <= TERM_WIDTH )); then
        [[ $cur_row_start -le 0 ]] && return
        local before_prev="${_IN_LINE:0:$((cur_row_start - 1))}"
        local prev_row_start=0
        if [[ "$before_prev" == *$'\n'* ]]; then
            local prev_content="${before_prev##*$'\n'}"
            prev_row_start=$((cur_row_start - 1 - ${#prev_content}))
        fi
        local prev_text="${_IN_LINE:prev_row_start:cur_row_start-prev_row_start-1}"
        # Use visual column to find matching char position on target row
        local _i _dw=0 _cd=0 _new_pos=0
        for ((_i=0; _i<${#prev_text}; _i++)); do
            _cd=$(_str_char_display_width "${prev_text:_i:1}")
            _dw=$((_dw + _cd))
            if (( _dw > vis_col )); then break; fi
            _new_pos=$((_i + 1))
        done
        _IN_POS=$((prev_row_start + _new_pos))
        _in_buf_reposition
        return
    fi

    # Wrapped row: compute visual row and move up one visual row
    local vis_row; vis_row=$(_in_visual_row_at "$_IN_POS")
    [[ $vis_row -le 0 ]] && return  # already at first visual row

    # Walk backward to find position at (vis_row - 1)
    local _i _ch _cd _vrow=0 _vcol=0 _prev_pos=0
    local _pw=$(_str_display_width "$(_strip_ansi_sgr "$_IN_PROMPT")")
    local _cpw=$(_str_display_width "$_IN_PROMPT_CONT")
    local _has_prompt=1 _eff_w=$((TERM_WIDTH - _pw))
    for ((_i=0; _i<${#_IN_LINE}; _i++)); do
        _ch="${_IN_LINE:_i:1}"
        if [[ "$_ch" == $'\n' ]]; then
            _vrow=$((_vrow + 1)); _vcol=0
            _has_prompt=1; _eff_w=$((TERM_WIDTH - _cpw))
        else
            _cd=$(_str_char_display_width "$_ch")
            if (( _vcol + _cd > _eff_w && _vcol > 0 )); then
                _vrow=$((_vrow + 1)); _vcol=$_cd
                _has_prompt=0; _eff_w=$TERM_WIDTH
            else
                _vcol=$((_vcol + _cd))
            fi
        fi
        if (( _vrow == vis_row - 1 )); then
            _prev_pos=$((_i + 1))
        fi
        if (( _vrow >= vis_row )); then
            break
        fi
    done

    # Position cursor at same visual column on previous visual row
    # For simplicity: place at end of previous visual row
    _IN_POS=$_prev_pos
    _in_buf_reposition
}

# Down: move cursor to same column in next display row
_in_buf_move_down() {
    local after="${_IN_LINE:_IN_POS}"
    local row=$(_in_display_row)
    local vis_col; vis_col=$(_in_visual_col)

    local cur_row_content=$(_in_get_row_content "$row")
    local cur_row_dw; cur_row_dw=$(_str_display_width "$cur_row_content")

    # Prompt width for this logical row
    local _row_pw=$(_str_display_width "$(_strip_ansi_sgr "$_IN_PROMPT")")
    (( row > 0 )) && _row_pw=$(_str_display_width "$_IN_PROMPT_CONT")

    # Fast path: current row fits, standard logic
    if (( cur_row_dw + _row_pw <= TERM_WIDTH )); then
        [[ "$after" != *$'\n'* ]] && return  # no next row
        local nl_content="${after%%$'\n'*}"
        local next_row_start=$((_IN_POS + ${#nl_content} + 1))
        local after_next="${_IN_LINE:next_row_start}"
        local next_text
        if [[ "$after_next" == *$'\n'* ]]; then
            next_text="${after_next%%$'\n'*}"
        else
            next_text="$after_next"
        fi
        # Use visual column to find matching char position on target row
        local _i _dw=0 _cd=0 _new_pos=0
        for ((_i=0; _i<${#next_text}; _i++)); do
            _cd=$(_str_char_display_width "${next_text:_i:1}")
            _dw=$((_dw + _cd))
            if (( _dw > vis_col )); then break; fi
            _new_pos=$((_i + 1))
        done
        _IN_POS=$((next_row_start + _new_pos))
        _in_buf_reposition
        return
    fi

    # Wrapped row: compute visual row and move down one
    local vis_row; vis_row=$(_in_visual_row_at "$_IN_POS")

    # Check if there's content at next visual row
    local _i _ch _cd _vrow=0 _vcol=0 _next_pos=-1
    local _pw=$(_str_display_width "$(_strip_ansi_sgr "$_IN_PROMPT")")
    local _cpw=$(_str_display_width "$_IN_PROMPT_CONT")
    local _has_prompt=1 _eff_w=$((TERM_WIDTH - _pw))
    for ((_i=0; _i<${#_IN_LINE}; _i++)); do
        _ch="${_IN_LINE:_i:1}"
        if [[ "$_ch" == $'\n' ]]; then
            _vrow=$((_vrow + 1)); _vcol=0
            _has_prompt=1; _eff_w=$((TERM_WIDTH - _cpw))
        else
            _cd=$(_str_char_display_width "$_ch")
            if (( _vcol + _cd > _eff_w && _vcol > 0 )); then
                _vrow=$((_vrow + 1)); _vcol=$_cd
                _has_prompt=0; _eff_w=$TERM_WIDTH
            else
                _vcol=$((_vcol + _cd))
            fi
        fi
        if (( _vrow == vis_row + 1 && _next_pos < 0 )); then
            _next_pos=$_i
            break
        fi
    done

    [[ $_next_pos -lt 0 ]] && return  # no next visual row
    _IN_POS=$_next_pos
    _in_buf_reposition
}

_in_buf_kill_to_end() {
    local after="${_IN_LINE:_IN_POS}"
    if [[ "$after" == *$'\n'* ]]; then
        # Kill to end of current display row only
        _IN_LINE="${_IN_LINE:0:_IN_POS}${after#*$'\n'}"
    else
        _IN_LINE="${_IN_LINE:0:_IN_POS}"
    fi
    _in_hist_touch
    # Strip orphaned trailing \n from emptied last line
    if [[ "$_IN_LINE" == *$'\n' ]] && [[ $_IN_POS -ge ${#_IN_LINE} ]]; then
        _IN_LINE="${_IN_LINE%$'\n'}"
        [[ $_IN_POS -gt ${#_IN_LINE} ]] && _IN_POS=${#_IN_LINE}
    fi
    _in_buf_redraw
}

_in_buf_kill_to_start() {
    local before="${_IN_LINE:0:_IN_POS}"
    if [[ "$before" == *$'\n'* ]]; then
        local rest="${before##*$'\n'}"
        _IN_LINE="${_IN_LINE:0:_IN_POS-${#rest}}${_IN_LINE:_IN_POS}"
        _IN_POS=$((_IN_POS - ${#rest}))
    else
        _IN_LINE="${_IN_LINE:_IN_POS}"
        _IN_POS=0
    fi
    _in_hist_touch
    # Strip orphaned trailing \n from emptied last line
    if [[ "$_IN_LINE" == *$'\n' ]] && [[ $_IN_POS -ge ${#_IN_LINE} ]]; then
        _IN_LINE="${_IN_LINE%$'\n'}"
        [[ $_IN_POS -gt ${#_IN_LINE} ]] && _IN_POS=${#_IN_LINE}
    fi
    _in_buf_redraw
}

_in_buf_kill_word_backward() {
    # Delete word before cursor (break on space, stop at \n boundary)
    [[ $_IN_POS -le 0 ]] && return
    local before="${_IN_LINE:0:_IN_POS}"
    # Get current row content (after last \n)
    local row_content="$before"
    [[ "$before" == *$'\n'* ]] && row_content="${before##*$'\n'}"
    # Trim trailing whitespace then non-whitespace
    local trimmed="${row_content% }"
    trimmed="${trimmed% }"
    if [[ "$trimmed" == *" "* ]]; then
        local word="${trimmed##* }"
        local trailing_spaces=$((${#row_content} - ${#trimmed}))
        local cut_len=$(( ${#word} + trailing_spaces ))
        _IN_LINE="${_IN_LINE:0:_IN_POS-cut_len}${_IN_LINE:_IN_POS}"
        _IN_POS=$((_IN_POS - cut_len))
    else
        # Kill the whole word/segment back to start of row
        _IN_LINE="${_IN_LINE:0:_IN_POS-${#row_content}}${_IN_LINE:_IN_POS}"
        _IN_POS=$((_IN_POS - ${#row_content}))
    fi
    _in_hist_touch
    # Strip orphaned trailing \n from emptied last line
    if [[ "$_IN_LINE" == *$'\n' ]] && [[ $_IN_POS -ge ${#_IN_LINE} ]]; then
        _IN_LINE="${_IN_LINE%$'\n'}"
        [[ $_IN_POS -gt ${#_IN_LINE} ]] && _IN_POS=${#_IN_LINE}
    fi
    _in_buf_redraw
}

# ── Blue flash: recolor entire input area on submit ──
# Replaces plain printf '\n' in the submit path. Renders all input lines
# with a blue background (dark/light mode matched to diff scheme), then
# positions cursor exactly where printf '\n' would have left it.
# SGR is re-applied after the prompt to work around terminal-specific
# SGR resets on the trailing space after the › glyph.
_in_submit_flash() {
    # Empty input: no flash, just advance
    [[ -z "$_IN_LINE" ]] && { printf '\n'; return; }

    local _sub_bg _sub_fg
    # Normal mode: blue
    _sub_bg="$FLASH_BG"
    _sub_fg="$FLASH_FG"
    # Safe mode override: warm amber
    if [[ "${BASHAGT_SAFE_MODE:-0}" == "1" ]]; then
        _sub_bg="$FLASH_SAFE_BG"
        _sub_fg="$FLASH_SAFE_FG"
    fi
    local _sub="$_sub_bg$_sub_fg"

    # 1. Jump to first visual row of input area
    if [[ ${_IN_CURSOR_ROW:-0} -gt 0 ]]; then
        printf '\033[%dA' "${_IN_CURSOR_ROW}"
    fi
    printf '\r'

    # 2. Redraw each logical line. Terminal may reset SGR on the space
    #    after › — strip it from the prompt, output separately under SGR.
    #    EL fills the remainder of the line (SGR bg on compliant terminals).
    local IFS=$'\n' _lines=() _i _total _pl _trim _EL
    _EL=$'\033[K'
    readarray -t _lines <<< "$_IN_LINE"
    _total=${#_lines[@]}
    for ((_i=0; _i<_total; _i++)); do
        _pl="$_IN_PROMPT_CONT"
        (( _i == 0 )) && _pl="$_IN_PROMPT"
        _trim="${_pl%" "}"
        printf '%s%s%s %s%s%s\n' \
            "$_sub" "$_trim" "$_sub" "${_lines[$_i]}" \
            "$_EL" "$RESET"
    done

    # 3. Reposition: we drew _IN_DISPLAY_LINES rows (incl auto-wrap).
    #    Target row = _IN_CURSOR_ROW + 1 (where printf '\n' would land).
    local _up
    _up=$(( ${_IN_DISPLAY_LINES:-1} - ${_IN_CURSOR_ROW:-0} - 1 ))
    if (( _up > 0 )); then
        printf '\033[%dA' "$_up"
    fi
}

# ── submit-or-newline: \ at end of buffer → insert \n, else submit ──
_in_buf_submit_or_newline() {
    # During paste, Enter always inserts newline (never submits)
    if [[ "${_IN_PASTING:-0}" == "1" ]]; then
        _IN_PASTE_LINE_COUNT=$((${_IN_PASTE_LINE_COUNT:-0} + 1))
        _in_buf_insert $'\n'
        return 1  # signal: continue editing
    fi

    # If buffer ends with odd number of \, the last \ is unescaped → continuation
    # (bash PS2 style: \\ → literal \; \\\ → literal \ + continuation)
    local _bs_count=0 _i=${#_IN_LINE}
    while (( _i > 0 )) && [[ "${_IN_LINE:_i-1:1}" == '\' ]]; do
        _bs_count=$((_bs_count + 1))
        _i=$((_i - 1))
    done
    if (( _bs_count % 2 == 1 )); then
        _IN_LINE="${_IN_LINE%'\'}"
        _IN_POS=${#_IN_LINE}
        _in_hist_touch
        _in_buf_insert $'\n'
        return 1  # signal: continue editing
    fi

    # Submit: blue-flash input area, then strip trailing newlines.
    _in_submit_flash
    REPLY="$_IN_LINE"
    while [[ "$REPLY" == *$'\n' ]]; do
        REPLY="${REPLY%$'\n'}"
    done
    _IN_SUBMIT=1
    return 0  # signal: submit
}

# ── Redraw: move cursor to start of input area, clear, render, position ──
_in_buf_redraw() {
    # Move cursor back to first line of input area
    if [[ ${_IN_CURSOR_ROW:-0} -gt 0 ]]; then
        printf '\033[%dA' "${_IN_CURSOR_ROW}"
    fi
    printf '\r'
    local _old_lines=${_IN_DISPLAY_LINES:-1}
    _input_render
    # If the buffer shrank (fewer display lines), clear the vacated rows.
    # \033[K in _input_render handles same-line shrinkage; this handles
    # multi-line shrinkage. Pasting (same or more lines) needs no clear.
    if (( _old_lines > _IN_DISPLAY_LINES )); then
        local _vacated=$((_IN_DISPLAY_LINES - _IN_CURSOR_ROW))
        printf '\033[%dB\r\033[J' "$_vacated"
        printf '\033[%dA' "$_vacated"
    fi
}

# ── _input_render — split buffer on \n, render with prompts, position cursor ──
_input_render() {
    local IFS=$'\n' lines=() i total row col pre

    # Handle empty buffer
    if [[ -z "$_IN_LINE" ]]; then
        printf '\r%s\033[K' "$_IN_PROMPT"
        _IN_DISPLAY_LINES=1
        _IN_CURSOR_ROW=0
        return
    fi

    # readarray reads ALL lines (unlike read -ra which only reads one)
    readarray -t lines <<< "$_IN_LINE"
    total=${#lines[@]}

    # Single-line buffer that fits in TERM_WIDTH — lightweight inline path.
    # Content wider than TERM_WIDTH (whether total==1 or total>1) is handled
    # by the unified multi-line path below.
    local _sc_pw; _sc_pw=$(_strip_ansi_sgr "$_IN_PROMPT")
    if (( total == 1 )) && (( $(_str_display_width "$_sc_pw") + $(_str_display_width "${lines[0]}") <= TERM_WIDTH )); then
        printf '\r%s%s\033[K' "$_IN_PROMPT" "${lines[0]}"
        col=$(_in_display_col)
        printf '\r%s%s' "$_IN_PROMPT" "${lines[0]:0:$col}"
        _IN_DISPLAY_LINES=1
        _IN_CURSOR_ROW=0
        return
    fi

    # ── Unified multi-line path ──
    # Handles: true multi-line (total>1), wide single-line (total==1 but
    # >TERM_WIDTH), and mixed (multi-line with auto-wrapping sub-lines).
    # Each logical line is rendered with its prompt + \033[K\n.  Terminal
    # auto-wrap is relied on for lines wider than TERM_WIDTH.
    for ((i=0; i<total; i++)); do
        pre="$_IN_PROMPT_CONT"
        [[ $i -eq 0 ]] && pre="$_IN_PROMPT"
        printf '%s%s\033[K\n' "$pre" "${lines[i]}"
    done

    # Cursor positioning — visual-row-aware fragment print.
    # Instead of reprinting prompt + full logical-line from the cursor's
    # visual row (which duplicates the prompt on continuation rows), we
    # print only the text segment that belongs on this visual row.
    local _vis_cursor _vis_total _up _row_start
    _vis_cursor=$(_in_visual_row_at "$_IN_POS")
    _vis_total=$(($(_in_visual_row_at "${#_IN_LINE}") + 1))

    _up=$((_vis_total - _vis_cursor))
    [[ $_up -gt 0 ]] && printf '\033[%dA' "$_up"
    printf '\r'

    if (( _vis_cursor == 0 )); then
        # First visual row — include the primary prompt
        col=$(_in_display_col)
        printf '%s%s' "$_IN_PROMPT" "${_IN_LINE:0:$col}"
    else
        _row_start=$(_in_pos_at_visual_row "$_vis_cursor")
        if (( _row_start > 0 )) && [[ "${_IN_LINE:_row_start-1:1}" == $'\n' ]]; then
            # New logical line — continuation prompt is already on screen
            printf '%s%s' "$_IN_PROMPT_CONT" "${_IN_LINE:_row_start:$((_IN_POS - _row_start))}"
        else
            # Auto-wrap continuation — bare text, no prompt
            printf '%s' "${_IN_LINE:_row_start:$((_IN_POS - _row_start))}"
        fi
    fi

    _IN_DISPLAY_LINES=$_vis_total
    _IN_CURSOR_ROW=$_vis_cursor
}

# ── Completer dispatch ──
_in_complete() {
    [[ -z "$_IN_COMPLETER" ]] && return
    local matches
    matches=$("$_IN_COMPLETER" "$_IN_LINE" 2>/dev/null) || return
    [[ -z "$matches" ]] && return

    # Split matches (cmd substitution already strips trailing \n;
    # here-string appends one, creating a trailing empty element we strip)
    local IFS=$'\n' arr=() cnt=0 i
    readarray -t arr <<< "$matches"
    cnt=${#arr[@]}
    # Drop trailing empty element from here-string appended newline
    if [[ $cnt -gt 0 && -z "${arr[$((cnt - 1))]}" ]]; then
        cnt=$((cnt - 1))
        unset 'arr[cnt]'
    fi

    if [[ $cnt -eq 1 ]]; then
        # Unique match: auto-complete with trailing space
        _IN_LINE="${arr[0]} "
        _IN_POS=${#_IN_LINE}
        _in_buf_redraw
    elif [[ $cnt -gt 1 ]]; then
        # Multiple matches: list below input
        printf '\n'
        local m
        for m in "${arr[@]}"; do
            [[ -n "$m" ]] && printf '  %s\n' "$m"
        done
        # Move back to input area before redraw (Bug A4)
        printf '\033[%dA' "$((cnt + 1))"
        _in_buf_redraw
    fi
}

# ── Key dispatch: map normalized key → action ──
_in_dispatch() {
    local key="$1" action

    # Literal characters (not in keymap)
    # [A-Z]?* matches multi-char uppercase keys (LEFT, RIGHT...) but NOT single
    # uppercase letters (B, A, S...), which must reach _in_buf_insert.
    if [[ "$key" != [A-Z]?* && "$key" != "C-"* && "$key" != "PASTE_"* && \
          "$key" != "EOF" && "$key" != "UNKNOWN" && \
          "$key" != "INSERT" && "$key" != "PGUP" && "$key" != "PGDN" ]]; then
        # Printable character (single or multi-byte UTF-8)
        _in_buf_insert "$key"
        return 0
    fi

    action="${_INPUT_KEYMAP[$key]:-}"
    if [[ -z "$action" ]]; then
        # Unmapped key — ignore (INSERT, PGUP, PGDN, UNKNOWN, etc.)
        return
    fi

    case "$action" in
        cursor-left)        _in_buf_move_left ;;
        cursor-right)       _in_buf_move_right ;;
        cursor-up)          _in_buf_move_up ;;
        cursor-down)        _in_buf_move_down ;;
        up-or-history)
            if [[ $(_in_display_row) -eq 0 ]]; then
                _in_hist_prev
            else
                _in_buf_move_up
            fi
            ;;
        down-or-history)
            local _total=$(_in_display_lines)
            if [[ $(_in_display_row) -eq $((_total - 1)) ]]; then
                _in_hist_next
            else
                _in_buf_move_down
            fi
            ;;
        cursor-home)        _in_buf_move_home ;;
        cursor-end)         _in_buf_move_end ;;
        cursor-buffer-start) _IN_POS=0; _in_buf_reposition ;;
        cursor-buffer-end)   _IN_POS=${#_IN_LINE}; _in_buf_reposition ;;
        delete-forward)     _in_buf_delete_forward ;;
        delete-backward)    _in_buf_delete_backward ;;
        submit-or-newline)  _in_buf_submit_or_newline || return 1 ;;  # continue editing
        complete)           _in_complete ;;
        kill-to-end)        _in_buf_kill_to_end ;;
        kill-to-start)      _in_buf_kill_to_start ;;
        kill-word-backward) _in_buf_kill_word_backward ;;
        eof-or-delete)
            if [[ -z "$_IN_LINE" ]]; then
                REPLY=""
                return 2  # EOF signal
            else
                _in_buf_delete_forward
            fi
            ;;
        clear-screen)
            printf '\033[2J\033[H'
            _in_buf_redraw
            ;;
        history-search)     _in_hist_search ;;
        history-prev)       _in_hist_prev ;;
        history-next)       _in_hist_next ;;
        paste-begin)
            _IN_PASTING=1
            _IN_PASTE_LINE_COUNT=0
            # Timestamp for stuck-paste timeout recovery (Bug 2 defense-in-depth)
            _IN_PASTE_START_TS=${EPOCHSECONDS:-$(date +%s)}
            ;;
        paste-end)
            _IN_PASTING=0
            while [[ "$_IN_LINE" == *$'\n' ]]; do
                _IN_LINE="${_IN_LINE%$'\n'}"
            done
            [[ $_IN_POS -gt ${#_IN_LINE} ]] && _IN_POS=${#_IN_LINE}
            # Render correct content FIRST — avoids visual flash of old
            # prompt + notification that looks like garbled/reversed text
            _in_buf_redraw
            if [[ ${_IN_PASTE_LINE_COUNT:-0} -gt 0 ]]; then
                local _stripped="${_IN_LINE//$'\n'/}"
                local _line_count=$((${#_IN_LINE} - ${#_stripped} + 1))
                printf '\n  [pasted %d lines]' "$_line_count"
                _IN_PASTE_LINE_COUNT=0
                # Clear notification then reposition cursor:
                # \r in the clear leaves cursor at col 0,
                # _in_buf_reposition restores it to the correct column.
                # /dev/tty: bypass stdout (may be broken FIFO during turn)
                printf '\033[1A\033[1B\r\033[K\033[1A' >/dev/tty
                _in_buf_reposition
            fi
            ;;
        safe-toggle)        _safe_toggle ;;
    esac
    return 0
}
# ── _input_exit_cleanup — EXIT trap safety net for terminal state ──
# Restores echo + bracketed paste on abnormal exit (SIGINT, crash, etc.)
# _input_cleanup() handles the normal-exit path; this is the safety net.
_input_exit_cleanup() {
    printf '\033[?2004l'
}

# ── Safe mode support ──
_safe_update_prompt() {
    local _mode="${1:-${BASHAGT_SAFE_MODE:-0}}"
    if [[ "$_mode" == "1" ]]; then
        _IN_PROMPT="  ${PROMPT_SAFE_ON}›${RESET} "
    else
        _IN_PROMPT="  ${PROMPT_INPUT}›${RESET} "
    fi
}

_safe_toggle() {
    if [[ "${BASHAGT_SAFE_MODE:-0}" == "1" ]]; then
        BASHAGT_SAFE_MODE=0
    else
        BASHAGT_SAFE_MODE=1
    fi
    _safe_update_prompt "$BASHAGT_SAFE_MODE"
    _in_buf_redraw
    log "SAFE_TOGGLE mode=$BASHAGT_SAFE_MODE" 2>/dev/null
}

_safe_toggle_signal() {
    if [[ "${BASHAGT_SAFE_MODE:-0}" == "1" ]]; then
        BASHAGT_SAFE_MODE=0
    else
        BASHAGT_SAFE_MODE=1
    fi
    _safe_update_prompt "$BASHAGT_SAFE_MODE"
    log "SAFE_TOGGLE_SIGNAL mode=$BASHAGT_SAFE_MODE" 2>/dev/null
}

# ── _input_init — initialize input layer ──
# Usage: _input_init [--prompt STR] [--history FILE] [--completer FUNC] [--raw --callback FUNC]
_input_init() {
    _IN_LINE=""
    _IN_POS=0
    _IN_PROMPT="  ›"
    _IN_PROMPT_CONT="  ⋯ "
    _IN_DISPLAY_LINES=1
    _IN_PASTING=0
    _IN_COMPLETER=""
    _IN_MODE="line"
    _IN_RAW_CALLBACK=""
    _IN_HIST_IDX=-1
    _IN_HIST_SAVED=""

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --prompt)   _IN_PROMPT="$2"; shift 2 ;;
            --history)  _IN_HIST_FILE="$2"; shift 2 ;;
            --completer) _IN_COMPLETER="$2"; shift 2 ;;
            --raw)      _IN_MODE="raw"; shift ;;
            --callback) _IN_RAW_CALLBACK="$2"; shift 2 ;;
            *) shift ;;
        esac
    done

    # Build default keymap (associative array, bash 4.0+)
    _INPUT_KEYMAP=(
        [LEFT]="cursor-left"
        [RIGHT]="cursor-right"
        [UP]="up-or-history"
        [DOWN]="down-or-history"
        [HOME]="cursor-home"
        [END]="cursor-end"
        [DELETE]="delete-forward"
        [BACKSPACE]="delete-backward"
        [ENTER]="submit-or-newline"
        [TAB]="complete"
        [S-TAB]="safe-toggle"
        [C-a]="cursor-buffer-start"
        [C-e]="cursor-buffer-end"
        [C-k]="kill-to-end"
        [C-u]="kill-to-start"
        [C-w]="kill-word-backward"
        [C-d]="eof-or-delete"
        [C-l]="clear-screen"
        [C-r]="history-search"
        [C-LEFT]="cursor-left"
        [C-RIGHT]="cursor-right"
        [PASTE_START]="paste-begin"
        [PASTE_END]="paste-end"
    )

    # Enable bracketed paste mode, disable terminal echo globally.
    # read -s only suppresses echo per-read inside $() subshells;
    # between calls echo is restored, causing paste escape sequences
    # (ESC[200~ etc.) to flash as "^[" garbage. stty -echo is persistent.
    printf '\033[?2004h'
    # Disable mouse reporting (SGR extended, cell motion, X10) — leftover
    # from a previous program would pollute the input stream with garbage.
    printf '\033[?1006l\033[?1002l\033[?1000l'
    # Disable XON/XOFF flow control so Ctrl-R/Ctrl-S reach the script.
    stty -ixon 2>/dev/null || true
    stty -echo 2>/dev/null || true
    # EXIT trap: only needs to disable bracketed paste.  stty is NOT restored
    # here — bash auto-restores terminal settings (tcsetattr) when the
    # foreground child exits.  Manual stty would fight bash's readline.
    trap '_input_exit_cleanup' EXIT

    # Respond to terminal resize
    trap '_update_term_width' WINCH

    # History: always initialize array, load from file if present
    _IN_HISTORY=()
    _IN_HIST_IDX=-1
    _IN_HIST_SAVED=""
    if [[ -n "${_IN_HIST_FILE:-}" && -f "$_IN_HIST_FILE" ]]; then
        _in_hist_load "$_IN_HIST_FILE"
    fi

    # Prompt is printed by _input_readline() on first call
}

# ── _input_cleanup — restore terminal, save history ──
_input_cleanup() {
    printf '\033[?2004l'
    stty echo 2>/dev/null || true
    trap - WINCH
    # EXIT trap restoration (daemon/MCP/history) is handled by main()'s
    # centralized EXIT trap — _input_cleanup only manages terminal state.

    # Save history (Phase 2)
    if [[ -n "${_IN_HIST_FILE:-}" ]]; then
        local _hlen=0
        if declare -p _IN_HISTORY &>/dev/null; then
            _hlen=${#_IN_HISTORY[@]}
        fi
        if (( _hlen > 0 )); then
            _in_hist_save "$_IN_HIST_FILE"
        fi
    fi
}

# ── History manager ──
# State is initialized in _input_init(): _IN_HISTORY array, _IN_HIST_IDX=-1,
# _IN_HIST_FILE path, _IN_HIST_SAVED for temp stash while browsing.
# Multi-line entries stored with \n → \x01 encoding (SOH, safe since Ctrl-A
# is consumed by keymap as cursor-home and never reaches the buffer).

# Load history entries from file into memory
_in_hist_load() {
    local file="$1" line
    _IN_HISTORY=()
    [[ -f "$file" ]] || return
    while IFS= read -r line || [[ -n "$line" ]]; do
        line="${line//$'\001'/$'\n'}"
        _IN_HISTORY+=("$line")
    done < "$file"
    _IN_HIST_IDX=-1
}

# Append to history file (encode \n as \x01). Uses $_IN_LINE if no line arg given.
_in_hist_save() {
    local file="$1" encoded line
    line="${2:-$_IN_LINE}"
    [[ -z "$file" ]] && return
    while [[ "$line" == *$'\n' ]]; do
        line="${line%$'\n'}"
    done
    [[ -z "$line" ]] && return
    encoded="${line//$'\n'/$'\001'}"
    printf '%s\n' "$encoded" >> "$file" 2>/dev/null || true
}

# Add line to in-memory history (called after submit)
_in_hist_add() {
    local line="$1"
    # Avoid consecutive duplicates
    [[ -z "${_IN_HISTORY+set}" ]] && return  # guard: history not initialized
    local len=${#_IN_HISTORY[@]}
    if [[ $len -gt 0 ]] && [[ "${_IN_HISTORY[$((len - 1))]}" == "$line" ]]; then
        return
    fi
    _IN_HISTORY+=("$line")
    # Cap at 1000 entries
    if [[ ${#_IN_HISTORY[@]} -gt 1000 ]]; then
        _IN_HISTORY=("${_IN_HISTORY[@]:1}")
    fi
    _IN_HIST_IDX=-1
}

# Browse to older history entry (Up arrow)
_in_hist_prev() {
    [[ -z "${_IN_HISTORY+set}" ]] && return
    local len=${#_IN_HISTORY[@]}
    [[ $len -eq 0 ]] && return

    if [[ $_IN_HIST_IDX -lt 0 ]]; then
        # First Up press: save current buffer
        _IN_HIST_SAVED="$_IN_LINE"
        _IN_HIST_IDX=$((len - 1))
    else
        [[ $_IN_HIST_IDX -gt 0 ]] && _IN_HIST_IDX=$((_IN_HIST_IDX - 1))
    fi
    _IN_LINE="${_IN_HISTORY[$_IN_HIST_IDX]}"
    _IN_POS=${#_IN_LINE}
    _in_buf_redraw
}

# Browse to newer history entry (Down arrow)
_in_hist_next() {
    [[ -z "${_IN_HISTORY+set}" ]] && return
    local len=${#_IN_HISTORY[@]}
    [[ ${_IN_HIST_IDX:--1} -lt 0 ]] && return  # not browsing

    if [[ $_IN_HIST_IDX -ge $((len - 1)) ]]; then
        # Past last entry: restore saved buffer
        _IN_HIST_IDX=-1
        _IN_LINE="${_IN_HIST_SAVED:-}"
        _IN_POS=${#_IN_LINE}
    else
        _IN_HIST_IDX=$((_IN_HIST_IDX + 1))
        _IN_LINE="${_IN_HISTORY[$_IN_HIST_IDX]}"
        _IN_POS=${#_IN_LINE}
    fi
    _in_buf_redraw
}

# Incremental history search (Ctrl-R) — stub, full implementation later
_in_hist_search() {
    [[ -z "${_IN_HISTORY+set}" ]] && { _in_buf_redraw; return; }
    local _hlen=${#_IN_HISTORY[@]}
    [[ $_hlen -eq 0 ]] && { _in_buf_redraw; return; }

    local _term="" _idx=$((_hlen - 1)) _saved_line="$_IN_LINE" _saved_pos=$_IN_POS
    local _match_idx=-1 _key _found=0 _i

    # Show search prompt below the input area
    printf '\n  (reverse-i-search)'"'"''"'"': '

    while :; do
        _input_read_key || { _term=""; break; }
        _key="$_INPUT_KEY_RET"

        case "$_key" in
            BACKSPACE|$'\177')
                [[ -n "$_term" ]] && _term="${_term%?}"
                ;;
            ENTER)
                break
                ;;
            C-g|C-d)
                # Cancel: restore original buffer
                _IN_LINE="$_saved_line"
                _IN_POS=$_saved_pos
                _term=""
                break
                ;;
            C-r)
                # Cycle to next match (older entry)
                _found=1
                if [[ $_match_idx -gt 0 ]]; then
                    _idx=$((_match_idx - 1))
                else
                    _idx=$((_hlen - 1))
                fi
                ;;
            *)
                # Printable: append to search term
                if [[ ${#_key} -eq 1 ]]; then
                    _term+="$_key"
                fi
                ;;
        esac

        # Search for term (newest-to-oldest, starting from _idx)
        if [[ -n "$_term" ]]; then
            _match_idx=-1
            for ((_i=_idx; _i>=0; _i--)); do
                [[ "${_IN_HISTORY[$_i]}" == *"$_term"* ]] && { _match_idx=$_i; _found=1; break; }
            done
            if [[ $_match_idx -ge 0 ]]; then
                _idx=$_match_idx
                _IN_LINE="${_IN_HISTORY[$_match_idx]}"
                _IN_POS=${#_IN_LINE}
            fi
        fi

        # Redraw the search UI line
        printf '\r\033[K'
        if [[ $_found -eq 1 && -n "$_term" ]]; then
            printf '(reverse-i-search)'"'"'%s'"'"': %s' "$_term" "$_IN_LINE"
        elif [[ -n "$_term" ]]; then
            printf '(failed reverse-i-search)'"'"'%s'"'"': ' "$_term"
        else
            printf '(reverse-i-search)'"'"''"'"': '
        fi
    done

    # Cleanup: clear search line, return to input area
    printf '\r\033[K\033[1A\033[J'
    if [[ -n "$_term" && $_found -eq 1 ]]; then
        # Accepted match — set buffer and redraw
        :  # _IN_LINE and _IN_POS already set to the match
        _in_buf_redraw
    else
        # Cancelled or no match — buffer already restored
        _in_buf_redraw
    fi
}

# ── _input_readline — main event loop ──
# Returns: 0=submit (REPLY set), 1=EOF (REPLY may have partial), 2=cancel
_input_readline() {
    local key rc

    # Reset buffer state for each new input line
    _IN_LINE=""
    _IN_POS=0
    _IN_SUBMIT=0
    _IN_HIST_IDX=-1
    _IN_PASTING=0

    # Display prompt (fresh each call, \r ensures column 0 regardless of
    # where previous turn output left the cursor)
    if [[ "$_IN_MODE" != "raw" ]]; then
        printf '\r%s' "$_IN_PROMPT"
        _IN_DISPLAY_LINES=1
        _IN_CURSOR_ROW=0
    fi

    while :; do
        _IN_SUBMIT=0
        _input_read_key || { REPLY="$_IN_LINE"; return 1; }
        key="$_INPUT_KEY_RET"

        # Raw mode: pass key to callback directly
        if [[ "$_IN_MODE" == "raw" ]]; then
            "$_IN_RAW_CALLBACK" "$key" || { REPLY=""; return 2; }
            continue
        fi

        # Line mode: dispatch through keymap.
        # rc=0 pre-init + || true prevents ERR trap on non-zero returns
        # (1=continue-editing, 2=Ctrl-D empty buffer — not real errors).
        rc=0
        _in_dispatch "$key" || { rc=$?; true; }
        if [[ $rc -eq 2 ]]; then
            return 1  # EOF
        fi

        # Defense-in-depth: if PASTE_END is lost (terminal emulator bug),
        # _IN_PASTING sticks at 1 permanently suppressing redraws.
        # Auto-reset after 30s — any real paste completes in <1s.
        if [[ "${_IN_PASTING:-0}" == "1" ]]; then
            local _paste_age=$((${EPOCHSECONDS:-$(date +%s)} - ${_IN_PASTE_START_TS:-0}))
            if (( _paste_age > 120 )); then
                _IN_PASTING=0
                _IN_PASTE_LINE_COUNT=0
                _in_buf_redraw
            fi
        fi

        if [[ $_IN_SUBMIT -eq 1 ]]; then
            return 0  # submitted
        fi
    done
}


# ============================================================================
# SECTION 3: Configuration Loading (4-tier: project > system > env > default)
# ============================================================================

load_config() {
    local sys_json="{}" prj_json="{}"
    [[ -f "$HOME/.bashagt/settings.json" ]] && sys_json=$(<"$HOME/.bashagt/settings.json")
    # Support both new (settings.json) and old (bashagt_setting.json) project config names
    local _prj_base="${BASHAGT_PROJECT_DIR:-.}"
    if [[ -f "$_prj_base/.bashagt/settings.json" ]]; then
        prj_json=$(<"$_prj_base/.bashagt/settings.json")
    elif [[ -f "$_prj_base/.bashagt/bashagt_setting.json" ]]; then
        prj_json=$(<"$_prj_base/.bashagt/bashagt_setting.json")
    fi

    # Batch-extract ALL config keys from merged JSON (prj > sys) in ONE jq call.
    # Each key outputs _CFG_<key>=<value>.  Keys not present → "".
    # This replaces ~94 individual jq subprocesses with 1.
    eval "$(jq -r -n --argjson sys "$sys_json" --argjson prj "$prj_json" '
        def sh: if type == "object" then tojson else . end | @sh;
        ($sys * $prj) as $c |
        "declare -g _CFG_api_key=\($c.api_key // "" | sh)",
        "declare -g _CFG_model=\($c.model // "" | sh)",
        "declare -g _CFG_api_url=\($c.api_url // "" | sh)",
        "declare -g _CFG_api_protocol=\($c.api_protocol // "" | sh)",
        "declare -g _CFG_auth_header=\($c.auth_header // "" | sh)",
        "declare -g _CFG_system_prompt=\($c.system_prompt // "" | sh)",
        "declare -g _CFG_max_tokens=\($c.max_tokens // "" | sh)",
        "declare -g _CFG_thinking_budget=\($c.thinking_budget // "" | sh)",
        "declare -g _CFG_show_thinking=\($c.show_thinking // "" | sh)",
        "declare -g _CFG_context_window=\($c.context_window // "" | sh)",
        "declare -g _CFG_context_safe_ratio=\($c.context_safe_ratio // "" | sh)",
        "declare -g _CFG_turn_budget_soft=\($c.turn_budget_soft // "" | sh)",
        "declare -g _CFG_turn_budget_hard=\($c.turn_budget_hard // "" | sh)",

        "declare -g _CFG_connect_timeout=\($c.connect_timeout // "" | sh)",
        "declare -g _CFG_cmd_timeout=\($c.cmd_timeout // "" | sh)",
        "declare -g _CFG_web_search_engine=\($c.web_search_engine // "" | sh)",
        "declare -g _CFG_web_search_timeout=\($c.web_search_timeout // "" | sh)",
        "declare -g _CFG_memory_enabled=\($c.memory_enabled // "" | sh)",
        "declare -g _CFG_memory_max_context=\($c.memory_max_context // "" | sh)",
        "declare -g _CFG_mem_engram_model=\($c.mem_engram_model // "" | sh)",
        "declare -g _CFG_mem_engram_count=\($c.mem_engram_count // "" | sh)",
        "declare -g _CFG_mem_engram_slots=\($c.mem_engram_slots // "" | sh)",
        "declare -g _CFG_todo_enabled=\($c.todo_enabled // "" | sh)",
        "declare -g _CFG_todo_max_context=\($c.todo_max_context // "" | sh)",
        "declare -g _CFG_mcp_enabled=\($c.mcp_enabled // "" | sh)",
        "declare -g _CFG_mcp_connect_timeout=\($c.mcp_connect_timeout // "" | sh)",
        "declare -g _CFG_mcp_request_timeout=\($c.mcp_request_timeout // "" | sh)",
        "declare -g _CFG_format_subagent=\($c.format_subagent // "" | sh)",
        "declare -g _CFG_format_max_tokens=\($c.format_max_tokens // "" | sh)",
        "declare -g _CFG_cache_enabled=\($c.cache_enabled // "" | sh)",
        "declare -g _CFG_cache_msg_tail=\($c.cache_msg_tail // "" | sh)",
        "declare -g _CFG_cache_probe_max_misses=\($c.cache_probe_max_misses // "" | sh)",
        "declare -g _CFG_cache_probe_reprobe=\($c.cache_probe_reprobe // "" | sh)",
        "declare -g _CFG_cache_api_support=\($c.cache_api_support // "" | sh)",
        "declare -g _CFG_cache_marker=\($c.cache_marker // "" | sh)",
        "declare -g _CFG_project_dir=\($c.project_dir // "" | sh)",
        "declare -g _CFG_daemon_port=\($c.daemon_port // "" | sh)",
        "declare -g _CFG_subproc_max=\($c.subproc_max // "" | sh)",
        "declare -g _CFG_dark_mode=\($c.dark_mode // "" | sh)",
        "declare -g _CFG_trace_enabled=\($c.trace_enabled // "" | sh)",
        "declare -g _CFG_trace_max_frames=\($c.trace_max_frames // "" | sh)",
        "declare -g _CFG_trace_snapshot_interval=\($c.trace_snapshot_interval // "" | sh)",
        "declare -g _CFG_trace_prune_keep=\($c.trace_prune_keep // "" | sh)",
        "declare -g _CFG_proxy_url=\($c.proxy_url // "" | sh)",
        "declare -g _CFG_proxy_user=\($c.proxy_user // "" | sh)",
        "declare -g _CFG_proxy_pass=\($c.proxy_pass // "" | sh)",
        "declare -g _CFG_proxy_noproxy=\($c.proxy_noproxy // "" | sh)"
    ' 2>/dev/null)"

    # Pure-bash 4-tier lookup: _CFG_<key> > env var > default.
    # No jq per key — all JSON extraction happened in the single jq above.
    _get_setting() {
        local _key="$1" _env_var="$2" _def="$3"
        local _ckey="_CFG_${_key}" _indirect _v
        _indirect="${!_ckey:-}"
        _v="${_indirect:-}"
        [[ "$_v" == "null" ]] && _v=""
        [[ -n "$_v" ]] && { printf '%s' "$_v"; return; }
        _indirect="${!_env_var:-}"
        _v="${_indirect:-}"
        [[ -n "$_v" ]] && { printf '%s' "$_v"; return; }
        printf '%s' "$_def"
    }

    BASHAGT_API_KEY=$(_get_setting "api_key" "BASHAGT_API_KEY" "")
    BASHAGT_API_URL=$(_get_setting "api_url" "BASHAGT_API_URL" "$DEFAULT_API_URL")
    BASHAGT_MODEL=$(_get_setting "model" "BASHAGT_MODEL" "$DEFAULT_MODEL")
    BASHAGT_API_PROTOCOL=$(_get_setting "api_protocol" "BASHAGT_API_PROTOCOL" "$DEFAULT_API_PROTOCOL")
    BASHAGT_MEM_ENGRAM_MODEL=$(_get_setting "mem_engram_model" "BASHAGT_MEM_ENGRAM_MODEL" "")
    BASHAGT_MEM_ENGRAM_COUNT=$(_get_setting "mem_engram_count" "BASHAGT_MEM_ENGRAM_COUNT" "$DEFAULT_MEM_ENGRAM_COUNT")
    BASHAGT_MEM_ENGRAM_SLOTS=$(_get_setting "mem_engram_slots" "BASHAGT_MEM_ENGRAM_SLOTS" "$DEFAULT_MEM_ENGRAM_SLOTS")
    BASHAGT_TRACE_ENABLED=$(_get_setting "trace_enabled" "BASHAGT_TRACE_ENABLED" "$DEFAULT_TRACE_ENABLED")
    BASHAGT_TRACE_MAX_FRAMES=$(_get_setting "trace_max_frames" "BASHAGT_TRACE_MAX_FRAMES" "$DEFAULT_TRACE_MAX_FRAMES")
    BASHAGT_TRACE_SNAPSHOT_INTERVAL=$(_get_setting "trace_snapshot_interval" "BASHAGT_TRACE_SNAPSHOT_INTERVAL" "$DEFAULT_TRACE_SNAPSHOT_INTERVAL")
    BASHAGT_TRACE_PRUNE_KEEP=$(_get_setting "trace_prune_keep" "BASHAGT_TRACE_PRUNE_KEEP" "$DEFAULT_TRACE_PRUNE_KEEP")
    MEM_ENGRAM_COUNT="${BASHAGT_MEM_ENGRAM_COUNT:-16}"
    MEM_ENGRAM_SLOTS="${BASHAGT_MEM_ENGRAM_SLOTS:-200}"
    MEM_TOTAL_CAPACITY=$((MEM_ENGRAM_COUNT * MEM_ENGRAM_SLOTS))
    BASHAGT_MAX_TOKENS=$(_get_setting "max_tokens" "BASHAGT_MAX_TOKENS" "$DEFAULT_MAX_TOKENS")
    BASHAGT_THINKING_BUDGET=$(_get_setting "thinking_budget" "BASHAGT_THINKING_BUDGET" "$DEFAULT_THINKING_BUDGET")
    BASHAGT_CONNECT_TIMEOUT=$(_get_setting "connect_timeout" "BASHAGT_CONNECT_TIMEOUT" "$DEFAULT_CONNECT_TIMEOUT")
    BASHAGT_CMD_TIMEOUT=$(_get_setting "cmd_timeout" "BASHAGT_CMD_TIMEOUT" "$DEFAULT_CMD_TIMEOUT")
    _set_project_paths  # set BASHAGT_HISTORY_FILE, TODO_FILE, MEM_NET_DIR, COMM_DIR from project dir
    BASHAGT_SYSTEM_PROMPT=$(_get_setting "system_prompt" "BASHAGT_SYSTEM_PROMPT" "$DEFAULT_SYSTEM_PROMPT")
    BASHAGT_SHOW_THINKING=$(_get_setting "show_thinking" "BASHAGT_SHOW_THINKING" "$DEFAULT_SHOW_THINKING")
    BASHAGT_AUTH_HEADER=$(_get_setting "auth_header" "BASHAGT_AUTH_HEADER" "")
    BASHAGT_FORMAT_SUBAGENT=$(_get_setting "format_subagent" "BASHAGT_FORMAT_SUBAGENT" "$DEFAULT_FORMAT_SUBAGENT")
    BASHAGT_FORMAT_MAX_TOKENS=$(_get_setting "format_max_tokens" "BASHAGT_FORMAT_MAX_TOKENS" "$DEFAULT_FORMAT_MAX_TOKENS")
    BASHAGT_TURN_BUDGET_SOFT=$(_get_setting "turn_budget_soft" "BASHAGT_TURN_BUDGET_SOFT" "$DEFAULT_TURN_BUDGET_SOFT")
    BASHAGT_TURN_BUDGET_HARD=$(_get_setting "turn_budget_hard" "BASHAGT_TURN_BUDGET_HARD" "$DEFAULT_TURN_BUDGET_HARD")
    BASHAGT_CONTEXT_WINDOW=$(_get_setting "context_window" "BASHAGT_CONTEXT_WINDOW" "$DEFAULT_CONTEXT_WINDOW")
    BASHAGT_CONTEXT_SAFE_RATIO=$(_get_setting "context_safe_ratio" "BASHAGT_CONTEXT_SAFE_RATIO" "$DEFAULT_CONTEXT_SAFE_RATIO")

    BASHAGT_WEB_SEARCH_ENGINE=$(_get_setting "web_search_engine" "BASHAGT_WEB_SEARCH_ENGINE" "$DEFAULT_WEB_SEARCH_ENGINE")
    BASHAGT_WEB_SEARCH_TIMEOUT=$(_get_setting "web_search_timeout" "BASHAGT_WEB_SEARCH_TIMEOUT" "$DEFAULT_WEB_SEARCH_TIMEOUT")

    BASHAGT_MEMORY_ENABLED=$(_get_setting "memory_enabled" "BASHAGT_MEMORY_ENABLED" "$DEFAULT_MEMORY_ENABLED")
    BASHAGT_MEMORY_MAX_CONTEXT=$(_get_setting "memory_max_context" "BASHAGT_MEMORY_MAX_CONTEXT" "$DEFAULT_MEMORY_MAX_CONTEXT")
    BASHAGT_TODO_ENABLED=$(_get_setting "todo_enabled" "BASHAGT_TODO_ENABLED" "$DEFAULT_TODO_ENABLED")
    BASHAGT_TODO_MAX_CONTEXT=$(_get_setting "todo_max_context" "BASHAGT_TODO_MAX_CONTEXT" "$DEFAULT_TODO_MAX_CONTEXT")
    BASHAGT_MCP_ENABLED=$(_get_setting "mcp_enabled" "BASHAGT_MCP_ENABLED" "$DEFAULT_MCP_ENABLED")
    BASHAGT_MCP_CONNECT_TIMEOUT=$(_get_setting "mcp_connect_timeout" "BASHAGT_MCP_CONNECT_TIMEOUT" "$DEFAULT_MCP_CONNECT_TIMEOUT")
    BASHAGT_MCP_REQUEST_TIMEOUT=$(_get_setting "mcp_request_timeout" "BASHAGT_MCP_REQUEST_TIMEOUT" "$DEFAULT_MCP_REQUEST_TIMEOUT")
    BASHAGT_PROJECT_DIR=$(_get_setting "project_dir" "BASHAGT_PROJECT_DIR" "$DEFAULT_PROJECT_DIR")
    # Only load from config if not overridden by CLI --port flag
    [[ -z "${BASHAGT_DAEMON_PORT:-}" ]] && BASHAGT_DAEMON_PORT=$(_get_setting "daemon_port" "BASHAGT_DAEMON_PORT" "$DEFAULT_DAEMON_PORT")
    BASHAGT_SUBPROC_MAX=$(_get_setting "subproc_max" "BASHAGT_SUBPROC_MAX" "$DEFAULT_SUBPROC_MAX")
    BASHAGT_CACHE_ENABLED=$(_get_setting "cache_enabled" "BASHAGT_CACHE_ENABLED" "$DEFAULT_CACHE_ENABLED")
    BASHAGT_CACHE_MSG_TAIL=$(_get_setting "cache_msg_tail" "BASHAGT_CACHE_MSG_TAIL" "$DEFAULT_CACHE_MSG_TAIL")
    BASHAGT_CACHE_PROBE_MAX_MISSES=$(_get_setting "cache_probe_max_misses" "BASHAGT_CACHE_PROBE_MAX_MISSES" "$DEFAULT_CACHE_PROBE_MAX_MISSES")
    BASHAGT_CACHE_PROBE_REPROBE=$(_get_setting "cache_probe_reprobe" "BASHAGT_CACHE_PROBE_REPROBE" "$DEFAULT_CACHE_PROBE_REPROBE")
    BASHAGT_CACHE_API_SUPPORT=$(_get_setting "cache_api_support" "BASHAGT_CACHE_API_SUPPORT" "$DEFAULT_CACHE_API_SUPPORT")
    BASHAGT_CACHE_MARKER=$(_get_setting "cache_marker" "BASHAGT_CACHE_MARKER" "$DEFAULT_CACHE_MARKER")
    BASHAGT_DARK_MODE=$(_get_setting "dark_mode" "BASHAGT_DARK_MODE" "$DEFAULT_DARK_MODE")
    BASHAGT_PROXY_URL=$(_get_setting "proxy_url" "BASHAGT_PROXY_URL" "")
    BASHAGT_PROXY_USER=$(_get_setting "proxy_user" "BASHAGT_PROXY_USER" "")
    BASHAGT_PROXY_PASS=$(_get_setting "proxy_pass" "BASHAGT_PROXY_PASS" "")
    BASHAGT_PROXY_NOPROXY=$(_get_setting "proxy_noproxy" "BASHAGT_PROXY_NOPROXY" "$DEFAULT_PROXY_NOPROXY")

    # ── Resolve API protocol: explicit > auto-detect from URL > default anthropic ──
    _resolve_protocol() {
        local _proto="${BASHAGT_API_PROTOCOL:-auto}"
        if [[ "$_proto" != "auto" ]]; then
            printf '%s' "$_proto"; return
        fi
        local _url="${BASHAGT_API_URL:-}"
        case "$_url" in
            */anthropic/*|*anthropic.com*)  printf 'anthropic'; return ;;
            */openai/*|*/v1/chat/completions*|*openai.com*) printf 'openai'; return ;;
            *deepseek*) printf 'anthropic'; return ;;  # deepseek uses anthropic protocol
            *) printf 'anthropic' ;;  # backward compatible default
        esac
    }
    BASHAGT_PROTOCOL=$(_resolve_protocol)

    BASHAGT_AUTH_PREFIX=""

    [[ -z "$BASHAGT_API_KEY" ]] && die "API key not set. Configure ~/.bashagt/settings.json or .bashagt/settings.json"

    if [[ -z "$BASHAGT_AUTH_HEADER" ]]; then
        if [[ "$BASHAGT_PROTOCOL" == "openai" ]]; then
            BASHAGT_AUTH_HEADER="Authorization"; BASHAGT_AUTH_PREFIX="Bearer "
        elif [[ "$BASHAGT_API_URL" == *deepseek* ]]; then
            BASHAGT_AUTH_HEADER="Authorization"; BASHAGT_AUTH_PREFIX="Bearer "
        elif [[ "$BASHAGT_API_URL" == *anthropic* ]]; then
            BASHAGT_AUTH_HEADER="x-api-key"; BASHAGT_AUTH_PREFIX=""
        else
            BASHAGT_AUTH_HEADER="x-api-key"; BASHAGT_AUTH_PREFIX=""
        fi
    fi
    log "DEBUG: [INIT] load_config: model=$BASHAGT_MODEL max_tok=$BASHAGT_MAX_TOKENS thinking=$BASHAGT_THINKING_BUDGET protocol=$BASHAGT_PROTOCOL"

    # ── Model profile loading ──
    BASHAGT_COMPRESS_PROFILE=$(_get_setting "compress_profile" "BASHAGT_COMPRESS_PROFILE" "")
    BASHAGT_ENGRAM_PROFILE=$(_get_setting "engram_profile" "BASHAGT_ENGRAM_PROFILE" "")
    _load_model_profiles
    BASHAGT_MAIN_PROFILE="${BASHAGT_MAIN_PROFILE:-}"
    _resolve_profile "$BASHAGT_MAIN_PROFILE" || true
    _ensure_model_pool
}

# Load BASHAGT.md project context
	load_bashagt_md() {
    local _f=".bashagt/BASHAGT.md" _cur
    if [[ -f "$_f" ]]; then
        _cur=$(_file_mtime "$_f")
        if [[ "$_cur" != "${_BASHAGT_MD_MTIME:-0}" ]]; then
            BASHAGT_MD=$(<"$_f")
            _BASHAGT_MD_MTIME="$_cur"
            SYS_JSON_CACHE=""
        fi
    else
        [[ -n "$BASHAGT_MD" ]] && { BASHAGT_MD=""; SYS_JSON_CACHE=""; }
    fi
    return 0
}

# ============================================================================
# SECTION 4: Message History
# ============================================================================

MESSAGES='[]'

# ── Message segment cache (incremental assembly) ──
MSG_COUNT=0
MSG_BP=-1
MSG_PREFIX_INNER=''           # messages[0..bp] inner elements (no outer [])
MSG_TAIL_INNER=''             # messages[bp+1..] inner elements (no outer [])
MSG_SEGMENTS_DIRTY=0

save_history() {
    local _tmp; _tmp=$(_mktemp_file .bashagt/history.XXXXXX 2>/dev/null)
    if [[ -n "$_tmp" ]]; then
        printf '%s' "$MESSAGES" > "$_tmp" && mv "$_tmp" "$BASHAGT_HISTORY_FILE"
    else
        printf '%s' "$MESSAGES" > "$BASHAGT_HISTORY_FILE"
    fi
    _cc_persist
}

load_history() {
    if [[ -f "$BASHAGT_HISTORY_FILE" ]]; then
        msg_replace_all "$(jq '[.[] | select(.content != [] and .content != "" and .content != null)]' "$BASHAGT_HISTORY_FILE" 2>/dev/null || echo '[]')"
    else
        msg_replace_all '[]'
    fi
    # First refresh with bp=-1 to get MSG_COUNT
    _msg_segments_refresh_from_messages
    # Pre-compute bp so first API call doesn't pay cold-start segment cost
    MSG_BP=$(( MSG_COUNT - ${BASHAGT_CACHE_MSG_TAIL:-2} - 1 ))
    (( MSG_BP < 0 )) && MSG_BP=-1
    if (( MSG_BP >= 0 && ${#MESSAGES} < 4096 )); then
        MSG_BP=$(( MSG_COUNT - 1 ))
    fi
    # Re-refresh with correct bp (2nd jq on MESSAGES, one-time init cost)
    if (( MSG_BP > 0 )); then
        _msg_segments_refresh_from_messages
    fi
    log "DEBUG: [HIST] loaded $MSG_COUNT messages bp=$MSG_BP"
}

msg_add_user_text() {
    local _msg; _msg=$(jq -nc --arg text "$1" '{role:"user","content":$text}')
    _msg_append_to_tail "$_msg"
    _cc_invalidate msgs
}

msg_add_assistant() {
    local _msg; _msg=$(jq -nc --argjson content "$1" '{role:"assistant","content":$content}')
    _msg_append_to_tail "$_msg"
    _cc_invalidate msgs
}

msg_add_tool_results() {
    local _msg; _msg=$(jq -nc --argjson results "$1" '{role:"user","content":$results}')
    _msg_append_to_tail "$_msg"
    _cc_invalidate msgs
}

# ── Message read accessors — single source of truth for JSON structure ──
msg_count() {
    jq 'length' <<< "$MESSAGES" 2>/dev/null || echo 0
}

msg_last_user_text() {
    jq -r '[.[] | select(.role=="user")] | last | .content // ""' <<< "$MESSAGES" 2>/dev/null
}

# Replace entire message array (for compression, /clear). Calls _cc_invalidate msgs.
msg_replace_all() {
    local new_json="$1"
    MESSAGES="$new_json"
    MSG_SEGMENTS_DIRTY=1
    _cc_invalidate msgs
}

# ============================================================================
# SECTION 5: Context Compression
# ============================================================================

# ── Compression helper: content hash for offload filenames ──
_comphash() {
    local _h; _h=$(printf '%s' "$1" | _cc_hash) && printf '%s\n' "${_h:0:8}"
}

# ── L0: Disk offloading + redundancy elimination ──
_compress_offload() {
    local _size_before=${#MESSAGES} _changed=0
    mkdir -p .bashagt/offload

    # ── Step 1: Redundancy elimination ──

    # 1a. Collapse thinking-only assistant turns → [thinking] placeholder
    local _new; _new=$(jq -c '
        def is_thinking_only:
            .role == "assistant" and (.content | type) == "array" and
            ([.content[] | select(.type == "text" or .type == "tool_use")] | length) == 0 and
            ([.content[] | select(.type == "thinking")] | length) > 0;
        [.[] | if is_thinking_only then {role:"assistant",content:[{type:"text",text:"[thinking]"}]} else . end]
    ' 2>/dev/null <<< "$MESSAGES") || return 0
    [[ "$_new" != "$MESSAGES" ]] && { MESSAGES="$_new"; _changed=1; }

    # 1b. Dedup consecutive identical user messages
    _new=$(jq -c '
        reduce .[] as $m ([]; if length > 0 and $m.role == "user" and .[-1].role == "user" and
            ($m.content | tostring) == (.[-1].content | tostring) then . else . + [$m] end)
    ' 2>/dev/null <<< "$MESSAGES") || return 0
    [[ "$_new" != "$MESSAGES" ]] && { MESSAGES="$_new"; _changed=1; }

    # 1c. Merge consecutive tool_results for the same tool_use_id (keep latest)
    _new=$(jq -c '
        reduce .[] as $m ([]; if length > 0 and $m.role == "user" and
            ([.content[]? | select(.type=="tool_result")] | length) > 0 and
            ([.[-1].content[]? | select(.type=="tool_result")] | length) > 0 and
            (.[-1].content | map(select(.type=="tool_result").tool_use_id) | sort) ==
            ($m.content | map(select(.type=="tool_result").tool_use_id) | sort)
            then .[-1] = $m else . + [$m] end)
    ' 2>/dev/null <<< "$MESSAGES") || return 0
    [[ "$_new" != "$MESSAGES" ]] && { MESSAGES="$_new"; _changed=1; }

    # ── Step 2: Disk offloading for large text blocks ──

    # Find blocks with text > 4000 chars
    local _large; _large=$(jq -c '
        [range(length) as $mi | .[$mi].content | to_entries |
         .[] | select(.value.text and (.value.text | length) > 4000) |
         {msg: $mi, blk: (.key | tonumber), len: (.value.text | length)}]
    ' 2>/dev/null <<< "$MESSAGES")
    local _count; _count=$(echo "$_large" | jq 'length' 2>/dev/null || echo 0)
    local _i _mi _bi _len _txt _hash _path _marker _trunc
    for ((_i=0; _i<_count; _i++)); do
        _mi=$(echo "$_large" | jq -r ".[$_i].msg")
        _bi=$(echo "$_large" | jq -r ".[$_i].blk")
        _len=$(echo "$_large" | jq -r ".[$_i].len")
        _txt=$(jq -r ".[$_mi].content[$_bi].text" <<< "$MESSAGES")
        # Base64 images: just replace with placeholder, no disk storage
        if [[ "$_txt" == data:image/*\;base64,* ]]; then
            _marker="[base64 image: $((_len/1024))KB]"
            MESSAGES=$(jq --argjson mi "$_mi" --argjson bi "$_bi" --arg m "$_marker" \
                '.[$mi].content[$bi].text = $m' <<< "$MESSAGES")
        else
            _hash=$(_comphash "$_txt")
            _path=".bashagt/offload/msg${_mi}_${_hash}.txt"
            printf '%s' "$_txt" > "$_path"
            _trunc="${_txt:0:8000}"
            _marker=" […offloaded→${_path} ($((_len/1024))KB)]"
            MESSAGES=$(jq --argjson mi "$_mi" --argjson bi "$_bi" \
                --arg trunc "$_trunc" --arg marker "$_marker" \
                '.[$mi].content[$bi].text = ($trunc + $marker)' <<< "$MESSAGES")
        fi
        _changed=1
    done

    [[ $_changed -eq 1 ]] && { _cc_invalidate msgs; log "L0 offload: ${_size_before}→${#MESSAGES} bytes"; }
    return 0
}


# ── L1 helper: build eviction marker ──
_build_tool_evict_marker() {
    local _tname="$1" _tinput="$2" _tsize="$3"
    printf '[↻ evicted: %s(%s) → was %s. Re-execute if needed.]' "$_tname" "$_tinput" "$_tsize"
}

# ── L1: Tool result eviction ──
_compress_tool_evict() {
    local _size_before=${#MESSAGES} _changed=0

    # Count total tool_result messages and build eviction map
    # A "round" = a user message; tool age = number of user msgs since the tool_use
    local _total_rounds; _total_rounds=$(jq '[.[] | select(.role=="user")] | length' 2>/dev/null <<< "$MESSAGES" || echo 0)
    (( _total_rounds < 5 )) && return 0  # too few rounds to bother

    # Build: for each tool_result, find its tool_use name+input and round index
    # Then check if it should be evicted (non-agent, age > retention)
    local _evict_list; _evict_list=$(jq -c --argjson tr "$_total_rounds" '
        # First pass: build tool_use registry {id: {name, input, round}}
        (foreach .[] as $m (
            {round:0, map:{}};
            if $m.role == "user" then .round += 1 else . end |
            if $m.role == "assistant" then
                reduce ($m.content[]? // empty) as $blk (.;
                    if $blk.type == "tool_use" then .map[$blk.id] = {name: $blk.name, input: ($blk.input//{}|tostring), round: .round}
                    else . end)
            else . end;
            .map
        )) as $tool_map
        |
        # Second pass: find evictable tool_results
        [range(length) as $mi | .[$mi] | select(.role=="user") |
         .content[]? | select(.type=="tool_result") |
         {msg: $mi, id: .tool_use_id} +
         {name: ($tool_map[.tool_use_id].name // ""),
          input: ($tool_map[.tool_use_id].input // ""),
          round: ($tool_map[.tool_use_id].round // 0)} |
         select(.name != "" and (.name | startswith("agent") | not)) |
         select(($tr - .round) > (if .name == "bash" or (.name | startswith("mcp__")) then 5 else 3 end))
        ]
    ' 2>/dev/null <<< "$MESSAGES")
    local _evict_count; _evict_count=$(echo "$_evict_list" | jq 'length' 2>/dev/null || echo 0)
    (( _evict_count == 0 )) && return 0

    # Process evictions
    local _i _mi _id _tname _tinput _tsize _marker
    for ((_i=0; _i<_evict_count; _i++)); do
        _mi=$(echo "$_evict_list" | jq -r ".[$_i].msg")
        _id=$(echo "$_evict_list" | jq -r ".[$_i].id")
        _tname=$(echo "$_evict_list" | jq -r ".[$_i].name")
        _tinput=$(echo "$_evict_list" | jq -r ".[$_i].input")
        _tsize=$(jq -r ".[$_mi].content[] | select(.type==\"tool_result\" and .tool_use_id==\"$_id\") | (.content//[]|tostring|length)" 2>/dev/null <<< "$MESSAGES")
        _marker=$(_build_tool_evict_marker "$_tname" "$_tinput" "${_tsize:-?}B")
        # Replace the tool_result content with the marker
        MESSAGES=$(jq --argjson mi "$_mi" --arg id "$_id" --arg marker "$_marker" '
            .[$mi].content = [.[$mi].content[] |
                if .type == "tool_result" and .tool_use_id == $id
                then {type:"tool_result", tool_use_id: $id, content: [{type:"text", text: $marker}]}
                else . end]
        ' <<< "$MESSAGES")
        _changed=1
    done

    [[ $_changed -eq 1 ]] && { _cc_invalidate msgs; log "L1 tool evict: evicted ${_evict_count} tool results"; }
    return 0
}

# ── L2: Selective message eviction ──
_compress_evict_selective() {
    local _size_before=${#MESSAGES} _changed=0

    local _total_rounds _total_msgs _trm
    _trm=$(jq -r '
      ([.[] | select(.role=="user")] | length | tostring) + " " + (length | tostring)
    ' 2>/dev/null <<< "$MESSAGES") || { _total_rounds=0; _total_msgs=0; }
    if [[ -n "${_trm:-}" ]]; then
        read -r _total_rounds _total_msgs <<< "$_trm"
    fi
    (( _total_msgs < 10 )) && return 0  # too few messages

    # Build scored message list: [msg_idx, score, round_age]
    local _scored; _scored=$(jq -c --argjson tr "$_total_rounds" '
        # Build round index per message index
        [foreach .[] as $m ({r:0, ri:[]};
            .ri += [.r] |
            if $m.role == "user" then .r += 1 else . end;
            {role: $m.role,
             round: .ri[-1],
             has_text: ([$m.content[]? | select(.type=="text" and (.text|length)>0)] | length > 0),
             has_tool: ([$m.content[]? | select(.type=="tool_use")] | length > 0),
             is_tool_result: ([$m.content[]? | select(.type=="tool_result")] | length > 0),
             tool_name: ([$m.content[]? | select(.type=="tool_use")] | .[0].name // ""),
             has_path: ([$m.content[]?.text? | test("(\\\\.py|\\\\.sh|\\\\.js|\\\\.ts|\\\\.go|\\\\.rs|\\\\.java|/src/|/lib/|/etc/)")] | any),
             has_error: ([$m.content[]?.text? | test("(?i)(error|exception|traceback|FAILED|failed|denied)")] | any),
             was_evicted: ([$m.content[]?.text? | test("↻ evicted")] | any),
             age: ($tr - .round)}
        ])
    ' 2>/dev/null)

    # Compute scores and get lowest-scored message indices — single jq, no temp files.
    # _score_message() logic inlined:
    #   role scoring: user=10, assistant+text=8, assistant+tool_only=2, assistant+none=3
    #                 tool_result+agent/agent_*=9, tool_result+bash=5, tool_result+mcp__*=5, other=3
    #   age bonus: age≤3→+5, 3<age≤8→+2, age≥10→-(age/10)
    #   content bonus: has_path→+3, has_error→+3, was_evicted→-2
    local _evict_indices
    _evict_indices=$(echo "$_scored" | jq -c '
      to_entries
      | map(.value + {idx: .key})
      | map(. + {
          score: (
            (if .role == "user" then 10
             elif .role == "assistant" then
               if .has_text then 8
               elif .has_tool then 2
               else 3 end
             else
               (if (.tool_name == "agent" or (.tool_name // "" | startswith("agent_"))) then 9
                elif .tool_name == "bash" then 5
                elif (.tool_name // "" | startswith("mcp__")) then 5
                else 3 end)
             end)
            + (if .age <= 3 then 5 elif .age <= 8 then 2 elif .age >= 10 then -((.age/10)|floor) else 0 end)
            + (if .has_path then 3 else 0 end)
            + (if .has_error then 3 else 0 end)
            + (if .was_evicted then -2 else 0 end)
          )
        })
      | sort_by(.score)
      | map(.idx)
      | join(" ")
    ' 2>/dev/null <<< "$MESSAGES")

    # Evict messages one by one until under threshold
    local _evicted=0 _evicted_first=99999 _evicted_last=0
    local _protected; _protected=$(jq -c '
        [0] + ([range(length)] | map(select(. as $i |
            # protect: last round (age <= 1), or unfinished tool chains
            (. > 0) and (
                ([.[$i].content[]? | select(.type=="tool_use")] | length > 0) and
                ([.[$i+1:][]?.content[]? | select(.type=="tool_result" and .tool_use_id == .)] | length == 0)
            )
        )))
    ' 2>/dev/null <<< "$MESSAGES")

    for _i in $_evict_indices; do
        # Skip protected messages
        echo "$_protected" | jq -e --argjson i "$_i" '. | index($i)' >/dev/null 2>&1 && continue
        # Skip last message
        (( _i >= _msg_count - 1 )) && continue

        # Mark for eviction (replace with placeholder)
        MESSAGES=$(jq --argjson i "$_i" 'del(.[$i])' <<< "$MESSAGES")
        _evicted=$((_evicted + 1))
        (( _i < _evicted_first )) && _evicted_first=$_i
        (( _i > _evicted_last )) && _evicted_last=$_i
        _changed=1

        # Check if we're under threshold now
        estimate_context_tokens
        local _thresh=$(( BASHAGT_CONTEXT_WINDOW * BASHAGT_CONTEXT_SAFE_RATIO / 100 ))
        (( ESTIMATED_CONTEXT_TOKENS < _thresh )) && break
    done

    if (( _evicted > 0 )); then
        # Prepend summary placeholder
        local _summary; _summary=$(printf '[Context trimmed: %d messages (rounds %d-%d) removed. Evicted tool results have re-execution markers; offloaded content is on disk. Re-execute tools or read_file offload paths if you need missing information.]' \
            "$_evicted" "$_evicted_first" "$_evicted_last")
        local _summary_json; _summary_json=$(printf '%s' "$_summary" | jq -Rs '.')
        MESSAGES=$(jq --argjson s "$_summary_json" '[{role:"user",content:[{type:"text",text:$s}]}] + .' <<< "$MESSAGES")
        _cc_invalidate msgs
        log "L2 selective evict: removed ${_evicted} low-score messages"
    fi

    return 0
}

# ── L3: API semantic summary ──
_compress_api() {
    local _async_mode="${1:-0}"

    # ── Compute keep/old_count ──
    local size=${#MESSAGES}
    local total tool_count _counts
    _counts=$(jq -r '
      (length | tostring) + " " +
      ([.[].content | if type=="array" then (.[] | select(.type=="tool_use")) else empty end] | length | tostring)
    ' 2>/dev/null <<< "$MESSAGES") || { total=0; tool_count=0; }
    if [[ -n "${_counts:-}" ]]; then
        read -r total tool_count <<< "$_counts"
    fi
    local keep=8
    (( tool_count > 4 )) && keep=12
    (( tool_count > 10 )) && keep=16
    (( total <= keep )) && return 0
    local old_count=$(( total - keep ))

    # Adjust split point to avoid breaking tool_use/tool_result pairs.
    # If the split falls between a tool_use (assistant msg) and its
    # tool_result (next user msg), the tool_use gets discarded by the
    # summary and the API rejects the orphaned tool_result.
    if (( old_count > 0 && old_count < total - 2 )); then
        local _pair_check; _pair_check=$(jq -r --argjson n "$old_count" '
            if .[$n].role == "user" and
               ([.[$n].content[]? | select(.type == "tool_result")] | length > 0) and
               ([.[$n-1].content[]? | select(.type == "tool_use")] | length > 0)
            then "adjust"
            else "ok" end
        ' 2>/dev/null <<< "$MESSAGES")
        if [[ "$_pair_check" == "adjust" ]]; then
            old_count=$((old_count + 1))
        fi
    fi

    # ── Common: profile resolution ──
    local _saved_prof="$_PROF_NAME"
    local _cpr_profile="${BASHAGT_COMPRESS_PROFILE:-${BASHAGT_MAIN_PROFILE:-}}"
    _resolve_profile "$_cpr_profile" || _resolve_profile ""
    local _cpr_model; _cpr_model=$(_prof_get_field model)
    local _cpr_url; _cpr_url=$(_prof_get_field api_url)
    local _cpr_proto; _cpr_proto=$(_prof_get_field protocol)
    local _cpr_auth_h; _cpr_auth_h=$(_prof_get_field auth_header)
    local _cpr_auth_px; _cpr_auth_px=$(_prof_get_field auth_prefix)
    local _cpr_key; _cpr_key=$(_prof_get_field api_key)
    local _cpr_max_tok_field="max_tokens"
    [[ "$_cpr_proto" == "openai" ]] && _cpr_max_tok_field="max_completion_tokens"
    local _cpr_av_arg=()
    [[ "$_cpr_proto" != "openai" ]] && _cpr_av_arg=(--header "anthropic-version: 2023-06-01")

    # ── Async: check result, check lock, launch background ──
    if (( _async_mode )); then
        # Check for completed result
        if [[ -f "$_COMPRESS_RESULT" ]]; then
            local _snap_count; _snap_count=$(jq -r '.snap_count // 0' "$_COMPRESS_RESULT" 2>/dev/null)
            local _snap_hash; _snap_hash=$(jq -r '.snap_hash // ""' "$_COMPRESS_RESULT" 2>/dev/null)
            local _cur_hash; _cur_hash=$(jq --argjson n "$_snap_count" '.[:$n]' <<< "$MESSAGES" | _cc_hash)
            if [[ -n "$_snap_hash" ]] && [[ "$_cur_hash" != "$_snap_hash" ]]; then
                log "WARN: L3 async discarded — messages modified in-place during compression"
                rm -f "$_COMPRESS_RESULT"
                _resolve_profile "$_saved_prof" || _resolve_profile ""
                return 0
            fi
            local _new_msgs; _new_msgs=$(jq '.messages' "$_COMPRESS_RESULT" 2>/dev/null)
            if [[ -n "$_new_msgs" && "$_new_msgs" != "null" ]]; then
                local _cur_count; _cur_count=$(jq 'length' <<< "$MESSAGES")
                if (( _cur_count > _snap_count )); then
                    local _new_only; _new_only=$(jq --argjson n "$_snap_count" '.[$n:]' <<< "$MESSAGES")
                    _new_msgs=$(echo "$_new_msgs" | jq --argjson tail "$_new_only" '. + $tail')
                fi
                local _new_size=${#_new_msgs}
                if (( _new_size < size )); then
                    MESSAGES="$_new_msgs"
                    log "L3 async: applied (${size}→${_new_size} bytes)"
                    LAST_COMPRESS_TURN="$TURN_COUNTER"
                    _cc_invalidate msgs
                    estimate_context_tokens
                fi
            fi
            rm -f "$_COMPRESS_RESULT"
            _resolve_profile "$_saved_prof" || _resolve_profile ""
            return 0
        fi
        # Skip if already running
        if [[ -d "$_COMPRESS_LOCK" ]]; then
            log "L3 async: already in progress, skipping"
            _resolve_profile "$_saved_prof" || _resolve_profile ""
            return 0
        fi

        log "L3 async: starting (${ESTIMATED_CONTEXT_TOKENS} tokens)"
        local _snap_msgs="$MESSAGES" _snap_size=$size _snap_total=$total _snap_keep=$keep _snap_old=$old_count
        (
            _lock_acquire_nb "$_COMPRESS_LOCK" || exit 0

            local _old_msgs; _old_msgs=$(echo "$_snap_msgs" | jq --argjson n "$_snap_old" '.[:$n]')
            local _body; _body=$(printf '%s' "$_old_msgs" | jq -Rs --argjson n "$_snap_old" --arg m "$_cpr_model" --arg mtf "$_cpr_max_tok_field" '{
                model: $m, messages: [{role:"user",content:("Summarize the following conversation into a brief paragraph. Preserve: key decisions, file paths touched, code changes made, errors encountered, and unresolved questions. Do NOT list every exchange — synthesize.\n\n"+.)}],
                stream: false, thinking: {type: "disabled"}} | .[$mtf] = 8192')

            local _summary _out; _out=$(_mktemp_file /tmp/bashagt_cpr.XXXXXX)
            if http_retry 2 100 5000 http_post "$_cpr_url" "$_out" --connect-timeout "${BASHAGT_CONNECT_TIMEOUT:-10}" --max-time 60 \
                --body "$_body" --auth-header "$_cpr_auth_h" --auth-value "${_cpr_auth_px}${_cpr_key}" \
                "${_cpr_av_arg[@]}" --header "content-type: application/json" && [[ -s "$_out" ]]; then
                _summary=$(< "$_out")
            else _summary='{"error":"cpr_failed"}'; fi
            rm -f "$_out"

            local _stext
            if [[ "$_cpr_proto" == "openai" ]]; then
                _stext=$(echo "$_summary" | jq -r '.choices[0].message.content // ""' 2>/dev/null)
            else
                _stext=$(echo "$_summary" | jq -r '.content[0].text // ""' 2>/dev/null)
            fi

            # Retry if failed
            if [[ -z "$_stext" ]] || [[ "${#_stext}" -lt 20 ]]; then
                if (( _snap_keep < _snap_total - 4 )); then
                    local _rk=$(( _snap_keep + _snap_total / 4 ))
                    (( _rk > _snap_total - 4 )) && _rk=$(( _snap_total - 4 ))
                    local _ro=$(( _snap_total - _rk ))
                    local _rmsgs; _rmsgs=$(echo "$_snap_msgs" | jq --argjson n "$_ro" '.[:$n]')
                    local _rbody; _rbody=$(printf '%s' "$_rmsgs" | jq -Rs --argjson n "$_ro" --arg m "$_cpr_model" --arg mtf "$_cpr_max_tok_field" '{
                        model: $m, messages: [{role:"user",content:("Summarize the following conversation into a brief paragraph. Preserve: key decisions, file paths touched, code changes made, errors encountered, and unresolved questions.\n\n"+.)}],
                        stream: false, thinking: {type: "disabled"}} | .[$mtf] = 8192')
                    local _rsum _rout; _rout=$(_mktemp_file /tmp/bashagt_cpr.XXXXXX)
                    if http_retry 2 100 5000 http_post "$_cpr_url" "$_rout" --connect-timeout "${BASHAGT_CONNECT_TIMEOUT:-10}" --max-time 60 \
                        --body "$_rbody" --auth-header "$_cpr_auth_h" --auth-value "${_cpr_auth_px}${_cpr_key}" \
                        "${_cpr_av_arg[@]}" --header "content-type: application/json" && [[ -s "$_rout" ]]; then
                        _rsum=$(< "$_rout")
                    else _rsum='{"error":"cpr_failed"}'; fi
                    rm -f "$_rout"
                    if [[ "$_cpr_proto" == "openai" ]]; then
                        _stext=$(echo "$_rsum" | jq -r '.choices[0].message.content // ""' 2>/dev/null)
                    else
                        _stext=$(echo "$_rsum" | jq -r '.content[0].text // ""' 2>/dev/null)
                    fi
                fi
                if [[ -z "$_stext" ]] || [[ "${#_stext}" -lt 20 ]]; then
                    log "WARN: L3 async API failed"
                    _lock_release "$_COMPRESS_LOCK"; exit 0
                fi
            fi

            local _recent; _recent=$(echo "$_snap_msgs" | jq --argjson n "$_snap_old" '.[$n:]')
            local _sj; _sj=$(printf '%s' "Previous conversation summary: $_stext" | jq -Rs '.')
            local _nm; _nm=$(echo "$_recent" | jq --argjson summary "$_sj" '[{role:"user",content:[{type:"text",text:$summary}]}] + .')
            local _ns=${#_nm}
            if (( _ns >= _snap_size )); then
                log "WARN: L3 async ineffective"
                _lock_release "$_COMPRESS_LOCK"; exit 0
            fi
            local _snap_hash; _snap_hash=$(echo "$_snap_msgs" | jq --argjson n "$_snap_total" '.[:$n]' | _cc_hash)
            jq -nc --argjson n "$_snap_total" --argjson msgs "$_nm" --arg h "$_snap_hash" \
                '{snap_count:$n,messages:$msgs,snap_hash:$h}' > "$_COMPRESS_RESULT"
            log "L3 async: done (${_snap_size}→${_ns} bytes)"
            _lock_release "$_COMPRESS_LOCK"
        ) 8>&- &
        _proc_register $! "agent" "compress_ctx"
        _resolve_profile "$_saved_prof" || _resolve_profile ""
        return 0
    fi

    # ── Synchronous path ──
    log "L3 compress: ${ESTIMATED_CONTEXT_TOKENS} tokens..."
    local _backup="$MESSAGES" _backup_size=$size
    local _old_msgs; _old_msgs=$(jq --argjson n "$old_count" '.[:$n]' <<< "$MESSAGES")
    local _body; _body=$(printf '%s' "$_old_msgs" | jq -Rs --argjson n "$old_count" --arg m "$_cpr_model" --arg mtf "$_cpr_max_tok_field" '{
        model: $m, messages: [{role:"user",content:("Summarize the following conversation into a brief paragraph. Preserve: key decisions, file paths touched, code changes made, errors encountered, and unresolved questions. Do NOT list every exchange — synthesize.\n\n"+.)}],
        stream: false, thinking: {type: "disabled"}} | .[$mtf] = 8192')

    local _summary _out; _out=$(_mktemp_file /tmp/bashagt_cpr.XXXXXX)
    if http_retry 2 100 5000 http_post "$_cpr_url" "$_out" --connect-timeout "${BASHAGT_CONNECT_TIMEOUT:-10}" --max-time 60 \
        --body "$_body" --auth-header "$_cpr_auth_h" --auth-value "${_cpr_auth_px}${_cpr_key}" \
        "${_cpr_av_arg[@]}" --header "content-type: application/json" && [[ -s "$_out" ]]; then
        _summary=$(< "$_out")
    else _summary='{"error":"cpr_failed"}'; fi
    rm -f "$_out"

    local _stext
    if [[ "$_cpr_proto" == "openai" ]]; then
        _stext=$(echo "$_summary" | jq -r '.choices[0].message.content // ""' 2>/dev/null)
    else
        _stext=$(echo "$_summary" | jq -r '.content[0].text // ""' 2>/dev/null)
    fi

    if [[ -z "$_stext" ]] || [[ "${#_stext}" -lt 20 ]]; then
        if (( keep < total - 4 )); then
            local _rk=$(( keep + total / 4 ))
            (( _rk > total - 4 )) && _rk=$(( total - 4 ))
            local _ro=$(( total - _rk ))
            local _rmsgs; _rmsgs=$(jq --argjson n "$_ro" '.[:$n]' <<< "$MESSAGES")
            local _rbody; _rbody=$(printf '%s' "$_rmsgs" | jq -Rs --argjson n "$_ro" --arg m "$_cpr_model" --arg mtf "$_cpr_max_tok_field" '{
                model: $m, messages: [{role:"user",content:("Summarize the following conversation into a brief paragraph. Preserve: key decisions, file paths touched, code changes made, errors encountered, and unresolved questions.\n\n"+.)}],
                stream: false, thinking: {type: "disabled"}} | .[$mtf] = 8192')
            local _rsum _rout; _rout=$(_mktemp_file /tmp/bashagt_cpr.XXXXXX)
            if http_retry 2 100 5000 http_post "$_cpr_url" "$_rout" --connect-timeout "${BASHAGT_CONNECT_TIMEOUT:-10}" --max-time 60 \
                --body "$_rbody" --auth-header "$_cpr_auth_h" --auth-value "${_cpr_auth_px}${_cpr_key}" \
                "${_cpr_av_arg[@]}" --header "content-type: application/json" && [[ -s "$_rout" ]]; then
                _rsum=$(< "$_rout")
            else _rsum='{"error":"cpr_failed"}'; fi
            rm -f "$_rout"
            if [[ "$_cpr_proto" == "openai" ]]; then
                _stext=$(echo "$_rsum" | jq -r '.choices[0].message.content // ""' 2>/dev/null)
            else
                _stext=$(echo "$_rsum" | jq -r '.content[0].text // ""' 2>/dev/null)
            fi
        fi
        if [[ -z "$_stext" ]] || [[ "${#_stext}" -lt 20 ]]; then
            MESSAGES="$_backup"
            log "WARN: L3 API failed — context restored ($_backup_size bytes)"
            LAST_COMPRESS_TURN="$TURN_COUNTER"
            _resolve_profile "$_saved_prof" || _resolve_profile ""
            return 0
        fi
    fi

    local _recent; _recent=$(jq --argjson n "$old_count" '.[$n:]' <<< "$MESSAGES")
    local _sj; _sj=$(printf '%s' "Previous conversation summary: $_stext" | jq -Rs '.')
    MESSAGES=$(echo "$_recent" | jq --argjson summary "$_sj" '[{role:"user",content:[{type:"text",text:$summary}]}] + .')

    local _new_size=${#MESSAGES}
    if (( _new_size >= _backup_size )); then
        MESSAGES="$_backup"
        log "WARN: L3 ineffective (${_backup_size}→${_new_size} bytes)"
        LAST_COMPRESS_TURN="$TURN_COUNTER"
        _resolve_profile "$_saved_prof" || _resolve_profile ""
        return 0
    fi

    log "L3 done: ${_backup_size}→${_new_size} bytes (kept ${keep}/${total})"
    LAST_COMPRESS_TURN="$TURN_COUNTER"
    _cc_invalidate msgs
    if [[ -n "$_stext" ]] && [[ "${BASHAGT_MEMORY_ENABLED:-$DEFAULT_MEMORY_ENABLED}" == "true" ]]; then
        ( ${TIMEOUT_CMD:-timeout} 60 call_agent "mem_writer" "Save this compressed conversation. Source: compress. Content: $_stext" >/dev/null 2>&1 || true ) &
        _proc_register $! "agent" "mem_writer_compress"
    fi
    _resolve_profile "$_saved_prof" || _resolve_profile ""
    return 0
}

# ── Orchestrator ──
compress_context() {
    local _async_mode=0
    [[ "${1:-}" == "--async" ]] && { _async_mode=1; shift; }

    estimate_context_tokens
    local token_threshold=$(( BASHAGT_CONTEXT_WINDOW * BASHAGT_CONTEXT_SAFE_RATIO / 100 ))
    (( ESTIMATED_CONTEXT_TOKENS < token_threshold )) && return 0
    log "DEBUG: COMPRESS   before=$ESTIMATED_CONTEXT_TOKENS threshold=$token_threshold"

    # ── pre_compress hook ──
    local _pc_ctx _pc_results _pc_item
    local _msg_count; _msg_count=$(jq 'length' <<< "$MESSAGES" 2>/dev/null || echo 0)
    _pc_ctx=$(jq -nc \
        --argjson est "$ESTIMATED_CONTEXT_TOKENS" \
        --argjson threshold "$token_threshold" \
        --argjson pct "$(( ESTIMATED_CONTEXT_TOKENS * 100 / BASHAGT_CONTEXT_WINDOW ))" \
        --argjson count "$_msg_count" \
        --argjson last_compress "${LAST_COMPRESS_TURN:-0}" \
        --argjson current "${TURN_COUNTER:-0}" \
        '{estimated_tokens:$est,threshold:$threshold,pct:$pct,messages_count:$count,last_compress_turn:$last_compress,current_turn:$current}')
    _pc_results=$(_hook_fire "pre_compress" "$_pc_ctx")
    if [[ "$_pc_results" != "[]" && -n "$_pc_results" ]]; then
        while IFS= read -r _pc_item; do
            [[ -z "$_pc_item" ]] && continue
            local _skip; _skip=$(jq -r '.skip_compress // false' <<< "$_pc_item" 2>/dev/null)
            [[ "$_skip" == "true" ]] && return 0
        done < <(jq -c '.[]' <<< "$_pc_results" 2>/dev/null)
    fi

    local _size_before=${#MESSAGES}

    _compress_offload
    estimate_context_tokens
    (( ESTIMATED_CONTEXT_TOKENS < token_threshold )) && { log "DEBUG: COMPRESS   after=$ESTIMATED_CONTEXT_TOKENS level=L0"; return 0; }

    _compress_tool_evict
    estimate_context_tokens
    (( ESTIMATED_CONTEXT_TOKENS < token_threshold )) && { log "DEBUG: COMPRESS   after=$ESTIMATED_CONTEXT_TOKENS level=L1"; return 0; }

    _compress_evict_selective
    estimate_context_tokens
    (( ESTIMATED_CONTEXT_TOKENS < token_threshold )) && { log "DEBUG: COMPRESS   after=$ESTIMATED_CONTEXT_TOKENS level=L2"; return 0; }

    _compress_api "$_async_mode"
    log "DEBUG: COMPRESS   after=$ESTIMATED_CONTEXT_TOKENS level=L3"
}


# ============================================================================
# SECTION 5b: Prompt Engineer — deterministic assembly + cache + adaptive probe
# ============================================================================
# The Prompt Engineer layer assembles the full API request body from 7 layers:
#   L1: BASHAGT.md (external, mtime-reloaded)
#   L2: System Prompt (built-in, §1-§7)
#   L3: Agent descriptions + Skill list (generated)
#   L4: Dynamic context (env info + memory + TODO) → messages[0]
#   L5: Message history prefix (cached) → messages[1..bp]
#   L6: Message tail (uncached) → messages[bp+1..]
#   L7: Tools JSON (cached)
# Each layer is independently cacheable via SHA256 content identity.
# This section also contains the local cache subsystem (_cc_*) and the
# Anthropic cache_control adaptive probe.
#
# Principle: Content Identity > Wall-Clock Time.
# Every cacheable component is identified by SHA256 hash. If content hasn't
# changed, the cached JSON is reused regardless of elapsed time.
#
# Three cache breakpoints (BP), in Anthropic-mandated prefix order:
#   BP1 — System prompt (BASHAGT.md + base_prompt + agent_list, cache_control injected)
#   BP2 — Tools JSON (last tool gets cache_control marker)
#   BP3 — Message prefix (messages[0..bp], tail excluded, bp gets cache_control)
# Field order: tools → system → messages (most-stable-first prefix matching).
#
# Adaptive probe: cache_control markers are always emitted (silently ignored
# by unsupported endpoints). The probe learns from response usage whether
# markers are effective.

# ── Cache Store ──
# Format: [component]="sha256hex:serialized_json"
declare -A _CC=(
    [sys_static]=""
    [msg_prefix]=""
)

# bp advance state removed — bp is computed from formula each API call

# ── Adaptive Probe State ──
declare -A _CACHE_PROBE=(
    [state]="probing"
    [consecutive_misses]=0
    [max_misses]=3
    [consecutive_hits]=0
    [total_hits]=0
    [total_probes]=0
    [inactive_since]=0
    [reprobe_after]=900
)

# ── Cache marker JSON (parsed from config, default Anthropic format) ──
CACHE_MARKER_JSON='{"cache_control":{"type":"ephemeral"}}'

# ── Hash (stdin → hex digest, portable: sha256sum/shasum/openssl/cksum) ──
_cc_hash() {
    local _raw
    if command -v sha256sum >/dev/null 2>&1; then
        _raw=$(sha256sum) && printf '%s\n' "${_raw%% *}"
    elif command -v shasum >/dev/null 2>&1; then
        _raw=$(shasum -a 256) && printf '%s\n' "${_raw%% *}"
    elif command -v openssl >/dev/null 2>&1; then
        _raw=$(openssl dgst -sha256) && _raw="${_raw##*= }" && printf '%s\n' "${_raw%% *}"
    else
        _raw=$(cksum) && printf '%s\n' "${_raw%% *}"
    fi
}

# ── Get cached component (returns 0 on hit, 1 on miss; outputs JSON on stdout) ──
_cc_get() {
    local _name="$1" _want_hash="$2"
    local _entry="${_CC[$_name]:-}"
    [[ -z "$_entry" ]] && return 1
    local _stored_hash="${_entry%%:*}"
    [[ "$_stored_hash" == "$_want_hash" ]] || return 1
    printf '%s' "${_entry#*:}"
    return 0
}

# ── Put component into cache ──
_cc_put() {
    local _name="$1" _hash="$2" _json="$3"
    _CC["$_name"]="$_hash:$_json"
}

# ── Invalidate one or more components ──
_cc_invalidate() {
    local _name
    for _name in "$@"; do
        case "$_name" in
            system)  _CC[sys_static]="" ;;
            msgs)    _CC[msg_prefix]=""; log "DEBUG: [CACHE] invalidate: msgs" ;;
        esac
    done
}

# ── Persist cache state to disk (for daemon session survival) ──
_cc_persist() {
    local _dir="${BASHAGT_PROJECT_DIR:-.}"
    [[ -d "$_dir/.bashagt" ]] || return 0
    local _pfx="${_CC[msg_prefix]:-}"
    local _sys="${_CC[sys_static]:-}"
    local _tmp; _tmp="$_dir/.bashagt/cache_state.tmp"
    local _mp_file="$_dir/.bashagt/.mp_tmp"
    printf '%s' "$_pfx" > "$_mp_file" 2>/dev/null
    jq -n -S \
        --arg ss "$_sys" \
        --rawfile mp "$_mp_file" \
        --arg ps "${_CACHE_PROBE[state]:-probing}" \
        --argjson pm "${_CACHE_PROBE[consecutive_misses]:-0}" \
        --argjson ph "${_CACHE_PROBE[total_hits]:-0}" \
        --argjson ch "${_CACHE_PROBE[consecutive_hits]:-0}" \
        --argjson tp "${_CACHE_PROBE[total_probes]:-0}" \
        --argjson pis "${_CACHE_PROBE[inactive_since]:-0}" \
        '{sys_static:$ss, msg_prefix:$mp,
          probe_state:$ps, probe_misses:$pm,
          probe_hits:$ph, probe_consecutive_hits:$ch, probe_total_probes:$tp,
          probe_inactive_since:$pis}' \
        > "$_tmp" 2>/dev/null && mv "$_tmp" "$_dir/.bashagt/cache_state.json" 2>/dev/null || true
    rm -f "$_mp_file"
}

# ── Restore cache state from disk ──
_cc_restore() {
    local _dir="${BASHAGT_PROJECT_DIR:-.}"
    local _file="$_dir/.bashagt/cache_state.json"
    [[ -f "$_file" ]] || return 0
    local _saved; _saved=$(<"$_file")

    # Batch extract all fields in one jq call.
    # Use eval with @sh to avoid read-with-tabs field-shifting bug.
    local _restore_code
    _restore_code=$(jq -r '
      "_sys_static=\( .sys_static       // ""       | @sh)",
      "_msg_prefix=\(  .msg_prefix       // ""       | @sh)",
      "_pstate=\(      .probe_state               // \"probing\" | @sh)",
      "_pmis=\(        .probe_misses              // 0        | tostring)",
      "_phit=\(        .probe_hits                // 0        | tostring)",
      "_pinact=\(      .probe_inactive_since      // 0        | tostring)",
      "_pcon=\(        .probe_consecutive_hits    // 0        | tostring)",
      "_pprob=\(       .probe_total_probes        // 0        | tostring)"
    ' <<< "$_saved" 2>/dev/null) || return 0
    [[ -n "$_restore_code" ]] || return 0
    # Initialize all vars to empty before eval (set -u guard)
    local _sys_static="" _msg_prefix=""
    local _pstate="" _pmis="" _phit="" _pinact="" _pcon="" _pprob=""
    eval "$_restore_code"

    [[ -n "$_sys_static" && "$_sys_static" != "null" ]] && _CC[sys_static]="$_sys_static"
    [[ -n "$_msg_prefix" && "$_msg_prefix" != "null" ]] && _CC[msg_prefix]="$_msg_prefix"
    [[ -n "$_pstate" && "$_pstate" != "null" ]] && _CACHE_PROBE[state]="$_pstate"
    [[ -n "$_pmis" && "$_pmis" != "null" ]] && _CACHE_PROBE[consecutive_misses]="$_pmis"
    [[ -n "$_phit" && "$_phit" != "null" ]] && _CACHE_PROBE[total_hits]="$_phit"
    [[ -n "$_pinact" && "$_pinact" != "null" ]] && _CACHE_PROBE[inactive_since]="$_pinact"
    [[ -n "$_pcon" && "$_pcon" != "null" ]] && _CACHE_PROBE[consecutive_hits]="$_pcon"
    [[ -n "$_pprob" && "$_pprob" != "null" ]] && _CACHE_PROBE[total_probes]="$_pprob"

    # If inactive > 24h, reset to probing (endpoint may have been updated)
    if [[ "${_CACHE_PROBE[state]}" == "inactive" ]]; then
        local _now _since
        _now=${EPOCHSECONDS:-$(date +%s)}
        _since="${_CACHE_PROBE[inactive_since]:-0}"
        if (( _now - _since > 86400 )); then
            _CACHE_PROBE[state]="probing"
            _CACHE_PROBE[consecutive_misses]=0
            _CACHE_PROBE[inactive_since]=0
        fi
    fi
}

# ── Probe feedback: called from call_api_nonstreaming on each response ──
_cache_probe_feedback() {
    local cache_read="${1:-0}" cache_create="${2:-0}" input_tokens="${3:-0}"
    local state="${_CACHE_PROBE[state]}"
    local _bf="${BASHAGT_PROJECT_DIR:-.}/.bashagt/cache_tokens.jsonl"
    if [[ "${BASHAGT_CACHE_DIAG:-0}" == "1" ]]; then
        printf '%s\n' "$(jq -nc --argjson rd "$cache_read" --argjson cr "$cache_create" --argjson tin "$input_tokens" '{cache_read:$rd, cache_create:$cr, input_tokens:$tin}')" >> "$_bf" 2>/dev/null || true
    fi
    [[ "$state" == "disabled" ]] && return 0

    _CACHE_PROBE[total_probes]=$(( ${_CACHE_PROBE[total_probes]} + 1 ))

    if (( cache_read > 0 || cache_create > 0 )); then
        _CACHE_PROBE[consecutive_hits]=$(( ${_CACHE_PROBE[consecutive_hits]} + 1 ))
        _CACHE_PROBE[total_hits]=$(( ${_CACHE_PROBE[total_hits]} + 1 ))
        _CACHE_PROBE[consecutive_misses]=0

        case "$state" in
            probing) _CACHE_PROBE[state]="active" ;;
            inactive) _CACHE_PROBE[state]="active" ;;  # re-enabled
        esac
        log "DEBUG: [CACHE] probe hit: read=$cache_read create=$cache_create state=${_CACHE_PROBE[state]}"
        return 0
    fi

    # No cache tokens in this response
    _CACHE_PROBE[consecutive_misses]=$(( ${_CACHE_PROBE[consecutive_misses]} + 1 ))
    _CACHE_PROBE[consecutive_hits]=0

    local _max="${_CACHE_PROBE[max_misses]}"
    case "$state" in
        probing)
            if (( ${_CACHE_PROBE[consecutive_misses]} >= _max )); then
                _CACHE_PROBE[state]="inactive"
                _CACHE_PROBE[inactive_since]=${EPOCHSECONDS:-$(date +%s)}
                log "WARN: cache probe failed after $_max attempts — disabling markers"
            fi
            ;;
        active)
            if (( ${_CACHE_PROBE[consecutive_misses]} >= _max )); then
                _CACHE_PROBE[state]="inactive"
                _CACHE_PROBE[inactive_since]=${EPOCHSECONDS:-$(date +%s)}
                log "WARN: cache markers stopped working — disabling"
            fi
            ;;
    esac
    log "DEBUG: [CACHE] probe: state=$state hit=$cache_read create=$cache_create misses=${_CACHE_PROBE[consecutive_misses]}/${_CACHE_PROBE[max_misses]}"
}

# ── Should emit cache_control markers? ──
_pe_cache_active() {
    local state="${_CACHE_PROBE[state]}"
    case "$state" in
        probing|active) return 0 ;;
        inactive)
            local _now _since _recheck
            _now=${EPOCHSECONDS:-$(date +%s)}
            _since="${_CACHE_PROBE[inactive_since]:-0}"
            _recheck="${_CACHE_PROBE[reprobe_after]:-900}"
            if (( _now - _since >= _recheck )); then
                _CACHE_PROBE[state]="probing"
                _CACHE_PROBE[consecutive_misses]=0
                log "INFO: re-probing cache support after $_recheck seconds"
                return 0
            fi
            return 1
            ;;
        *) return 1 ;;  # disabled or unknown — do not emit
    esac
}

# ── _pe_cache_init: Initialize cache subsystem ──
_pe_cache_init() {
    # OpenAI protocol does not support Anthropic cache_control
    if [[ "${BASHAGT_PROTOCOL:-anthropic}" == "openai" ]]; then
        BASHAGT_CACHE_ENABLED="false"
    fi

    # Parse cache marker JSON from config.
    # Use explicit if/else rather than ${VAR:-default} — the nested braces
    # in the JSON default can confuse bash's brace-counting parser.
    local _marker
    if [[ -n "${BASHAGT_CACHE_MARKER:-}" ]]; then
        _marker="$BASHAGT_CACHE_MARKER"
    else
        _marker='{"cache_control":{"type":"ephemeral"}}'
    fi
    # Validate: must be valid JSON object
    if jq -e '.' <<< "$_marker" >/dev/null 2>&1; then
        CACHE_MARKER_JSON="$_marker"
    else
        log "WARN: invalid cache_marker JSON — using default"
    fi

    # Apply probe config
    _CACHE_PROBE[max_misses]="${BASHAGT_CACHE_PROBE_MAX_MISSES:-3}"
    _CACHE_PROBE[reprobe_after]="${BASHAGT_CACHE_PROBE_REPROBE:-900}"

    # Master off-switch
    if [[ "${BASHAGT_CACHE_ENABLED:-true}" != "true" ]]; then
        _CACHE_PROBE[state]="disabled"
        return 0
    fi

    # Handle explicit off
    if [[ "${BASHAGT_CACHE_API_SUPPORT:-auto}" == "off" ]]; then
        _CACHE_PROBE[state]="disabled"
        return 0
    fi

    # Restore persisted state (daemon sessions)
    _cc_restore

    # If restored state is "disabled" but current config has caching on,
    # config wins — persisted disable was from a previous config
    if [[ "${_CACHE_PROBE[state]}" == "disabled" ]]; then
        _CACHE_PROBE[state]="probing"
        _CACHE_PROBE[consecutive_misses]=0
    fi

    log "DEBUG: [CACHE] _pe_cache_init: enabled state=${_CACHE_PROBE[state]:-probing} api=${BASHAGT_CACHE_API_SUPPORT:-auto}"

    # If forced, always emit markers (never enter inactive)
    if [[ "${BASHAGT_CACHE_API_SUPPORT:-auto}" == "force" ]]; then
        _CACHE_PROBE[state]="probing"
        _CACHE_PROBE[max_misses]=999999
    fi
}

# bp advance removed — bp is computed from formula each API call (per-turn advance).
# See _pe_assemble_request and call_api_nonstreaming for bp computation logic.

# ═══════════════════════════════════════════════════════════════════════════
# INLINE FORMAT HELPERS — fast-path detection + content-block tag stripping
# ═══════════════════════════════════════════════════════════════════════════


# Strip format color tags from CONTENT_BLOCKS JSON (for clean history storage).
# Only modifies ".type == text" blocks; tool_use/tool_result/thinking untouched.
# Uses chained jq gsub calls (literal string replacement, no regex ambiguity).
# ═══════════════════════════════════════════════════════════════════════════
# COMPONENT BUILDERS — each checks hash cache before rebuilding
# ═══════════════════════════════════════════════════════════════════════════

# ── Reload helpers: detect disk changes via mtime ──

# Check skill directories for mtime changes; reload and invalidate cache if stale.
# Called by _pe_assemble_system() each turn before building the skill list.
	_reload_skills_if_stale() {
    local _cur=0 _dir
    for _dir in "$HOME/.bashagt/skills/" .bashagt/skills/; do
        [[ -d "$_dir" ]] && { local _m; _m=$(_file_mtime "$_dir"); (( _m > _cur )) && _cur=$_m; }
    done
    if (( _cur != ${_SKILL_DIR_MTIME:-0} )); then
        load_skills
        _SKILL_DIR_MTIME=$_cur
        SYS_JSON_CACHE=""
    fi
}

# Returns 0 (true) if BASHAGT_MD has non-comment, non-whitespace content
_bashagt_md_has_content() {
    [[ -n "${BASHAGT_MD:-}" ]] || return 1
    local _stripped
    _stripped=$(grep -v '^[[:space:]]*\(#.*\)\?$' <<< "$BASHAGT_MD" 2>/dev/null)
    [[ -n "${_stripped:-}" ]]
}

# ── Context cache (hash-driven incremental rebuild) ──
CONTEXT_STATIC=''              # static prefix: pwd + git + platform + shell + model
CONTEXT_DYN_CACHED=''          # full dyn_msg JSON: {role:"user", content:[{...}]}
CONTEXT_HASH=''                # composite: time|memory_hash|todo_mtime

# ── Skill list cache (avoid N jq-r forks per turn) ──
SKILL_LIST_CACHED=''           # pre-built skill list string (with header)
SKILL_LIST_MTIME=0             # _SKILL_DIR_MTIME when cached

# ── Message segment cache helpers ──

# Append a pre-serialized JSON message to both MESSAGES and TAIL segment.
# Pure bash — no jq, no fork. O(1) regardless of MESSAGES size.
_msg_append_to_tail() {
    local _new_msg_json="$1"
    if [[ "$MESSAGES" == '[]' ]]; then
        MESSAGES="[$_new_msg_json]"
    else
        MESSAGES="${MESSAGES%\]},${_new_msg_json}]"
    fi
    if [[ -z "$MSG_TAIL_INNER" ]]; then
        MSG_TAIL_INNER="$_new_msg_json"
    else
        MSG_TAIL_INNER+=",${_new_msg_json}"
    fi
    MSG_COUNT=$((MSG_COUNT + 1))
}

# Advance bp: move _shift messages from TAIL head to PREFIX tail.
# Uses 1 small jq on TAIL (usually 2-5 messages, not full MESSAGES).
_msg_segments_advance_bp() {
    local _new_bp="$1"
    local _shift=$((_new_bp - MSG_BP))
    (( _shift <= 0 )) && return 0
    local _result; _result=$(jq -c --argjson n "$_shift" '
        {move: .[:$n], rest: .[$n:]}
    ' <<< "[${MSG_TAIL_INNER}]")
    local _move; _move=$(jq -c '.move' <<< "$_result")
    local _rest; _rest=$(jq -c '.rest' <<< "$_result")
    # move → PREFIX inner
    if [[ "$_move" != '[]' ]]; then
        local _move_inner="${_move:1:$(( ${#_move} - 2 ))}"
        if [[ -z "$MSG_PREFIX_INNER" ]]; then
            MSG_PREFIX_INNER="$_move_inner"
        elif [[ -n "$_move_inner" ]]; then
            MSG_PREFIX_INNER+=",${_move_inner}"
        fi
    fi
    # rest → TAIL inner
    if [[ "$_rest" == '[]' ]]; then
        MSG_TAIL_INNER=''
    else
        MSG_TAIL_INNER="${_rest:1:$(( ${#_rest} - 2 ))}"
    fi
    MSG_BP=$_new_bp
}

# Full rebuild of segment cache from MESSAGES (1 jq, only when segments are dirty).
# Includes content normalization for legacy string-content messages.
_msg_segments_refresh_from_messages() {
    local _bp1=$((MSG_BP + 1)) _jsonl
    _jsonl=$(jq -c --argjson bp1 "$_bp1" '
        def normalize: .content |= (if type == "string" then [{type:"text", text:.}] else . end);
        (.[:$bp1] | map(normalize)),
        (.[$bp1:] | map(normalize)),
        length
    ' <<< "$MESSAGES")
    { IFS= read -r _pfx_array
      IFS= read -r _tail_array
      IFS= read -r MSG_COUNT
    } <<< "$_jsonl"
    # Strip outer brackets: '[]' → '', '[{...}]' → '{...}'
    if [[ "$_pfx_array" == '[]' ]]; then
        MSG_PREFIX_INNER=''
    else
        MSG_PREFIX_INNER="${_pfx_array:1:$(( ${#_pfx_array} - 2 ))}"
    fi
    if [[ "$_tail_array" == '[]' ]]; then
        MSG_TAIL_INNER=''
    else
        MSG_TAIL_INNER="${_tail_array:1:$(( ${#_tail_array} - 2 ))}"
    fi
    MSG_SEGMENTS_DIRTY=0
}

# ── Context cache helpers ──

# Build static context prefix once (pwd, git, platform, shell, model never change).
_context_static_init() {
    local _git; _git=$(git rev-parse --git-dir 2>/dev/null >/dev/null && echo yes || echo no)
    printf -v CONTEXT_STATIC '%s\n%s\n%s\n%s\n%s\n' \
        "Working directory: $(pwd)" \
        "Git repository: $_git" \
        "Platform: $(uname -s) ($(uname -m))" \
        "Shell: ${SHELL##*/}" \
        "Model: ${BASHAGT_MODEL:-unknown}"
}

# Composite hash: time + memory_pool hash + todo.json mtime.
# Returns a string that changes only when context inputs change.
_context_get_hash() {
    local _t _tm=0
    printf -v _t '%(%Y-%m-%d %H:%M)T' -1
    local _tf="${BASHAGT_PROJECT_DIR:-.}/.bashagt/todo.json"
    [[ -f "$_tf" ]] && _tm=$(_file_mtime "$_tf")
    printf -v _CONTEXT_HASH_VAL '%s|%s|%s' "$_t" "${MEMORY_CACHE_TS:-0}" "$_tm"
}

# Rebuild dyn_msg from current context inputs. Called only when hash changes.
_context_rebuild() {
    local _now_time; printf -v _now_time '%(%Y-%m-%d %H:%M)T' -1
    local ctx="${CONTEXT_STATIC}Time: ${_now_time}"
    local memory_ctx todo_ctx
    memory_ctx=$(build_memory_context 2>/dev/null) || true
    todo_ctx=$(build_todo_context 2>/dev/null) || true
    [[ -n "$memory_ctx" ]] && ctx+=$'\n\n'"$memory_ctx"
    [[ -n "$todo_ctx" ]] && ctx+=$'\n\n'"$todo_ctx"
    CONTEXT_DYN_CACHED=$(jq -n -S --rawfile txt <(printf '%s' "$ctx") \
        '{role:"user", content:[{type:"text", text:$txt}]}')
    _context_get_hash
    CONTEXT_HASH="$_CONTEXT_HASH_VAL"
}

# ── Skill list cache: rebuild only when skills change (_SKILL_DIR_MTIME) ──
_skill_list_refresh() {
    if (( _SKILL_DIR_MTIME == SKILL_LIST_MTIME )) && [[ -n "$SKILL_LIST_CACHED" ]]; then
        return 0
    fi
    local _sk_count=0 _sk_list=''
    for _sn in "${!SKILL_META[@]}"; do
        (( _sk_count >= 200 )) && break
        local _sd; _sd=$(echo "${SKILL_META[$_sn]}" | jq -r '.description // ""' 2>/dev/null)
        local _tag=""
        [[ " ${ACTIVE_SKILLS[*]} " =~ " ${_sn} " ]] && _tag=" [active]"
        _sk_list+=$'\n'"  ${_sn}  ${_sd}${_tag}"
        _sk_count=$((_sk_count + 1))
    done
    if [[ -n "$_sk_list" ]]; then
        SKILL_LIST_CACHED=$'\n\n'"Available skills (use skill(\"<name>\", \"<task>\") to invoke or list_skills to discover):"$_sk_list
    else
        SKILL_LIST_CACHED=''
    fi
    SKILL_LIST_MTIME=$_SKILL_DIR_MTIME
}

# ── BP1: Static system prompt (BASHAGT.md + base_prompt + agent_list) ──
# Dynamic context (CWD/TODO/memory) is deliberately EXCLUDED — it goes
# into messages[0] so the system prompt stays byte-identical across turns.
_pe_assemble_system() {
    local _emit_markers="${1:-0}"
    # NOTE: load_bashagt_md / _reload_skills_if_stale are called in
    # call_api_nonstreaming() (main shell) before the $(...) subshell chain,
    # so globals _BASHAGT_MD_MTIME / _SKILL_DIR_MTIME persist across turns.

    local _identity

    if _bashagt_md_has_content; then
        # BASHAGT.md has real content → highest priority, replaces default §1
        _identity="━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
§1 — ROLE & IDENTITY (from BASHAGT.md)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

$BASHAGT_MD
"
    else
        _identity="$_DEFAULT_SP_IDENTITY"
    fi

    local raw="$_DEFAULT_SP_PREAMBLE
$_identity
$_DEFAULT_SP_SAFETY
$_DEFAULT_SP_REST

Available sub-agents — use agent(\"agent_name\", \"prompt\") for complex multi-step work.
Prefer agent() for: planning before coding, code review, complex searches, summarization.
${AGENT_DESCRIPTIONS}"

    # Inject skill list from cache (rebuilt only when _SKILL_DIR_MTIME changes)
    _skill_list_refresh
    [[ -n "$SKILL_LIST_CACHED" ]] && raw+="$SKILL_LIST_CACHED"

    local hash; hash=$(printf '%s' "$raw" | _cc_hash)

    local json
    local cached; cached=$(_cc_get "sys_static" "$hash" 2>/dev/null)
    if [[ -n "$cached" ]]; then
        json="$cached"
    else
        json=$(jq -n -S --rawfile txt <(printf '%s' "$raw") \
            '[{type:"text", text:$txt}]')
        _cc_put "sys_static" "$hash" "$json"
    fi

    # Inject cache_control on last content block (BP1: system prompt)
    if [[ "$_emit_markers" == "1" ]]; then
        json=$(jq -S --argjson CM "$CACHE_MARKER_JSON" \
            'if length > 0 then .[-1] += $CM else . end' <<< "$json")
    fi
    printf '%s' "$json"
}

# ── Dynamic context message (always rebuilt, placed as messages[0]) ──
_pe_assemble_context() {
    # Lazy init: static prefix (pwd, git, platform, shell, model) built once
    [[ -z "$CONTEXT_STATIC" ]] && _context_static_init

    _context_get_hash
    if [[ "$_CONTEXT_HASH_VAL" != "$CONTEXT_HASH" ]] || [[ -z "$CONTEXT_DYN_CACHED" ]]; then
        _context_rebuild
        CONTEXT_HASH="$_CONTEXT_HASH_VAL"
    fi
    _PE_DYN_MSG="$CONTEXT_DYN_CACHED"
}

# ── Message prefix builder (legacy, used by tests) ──
_pe_assemble_msg_prefix() {
    local bp="$1"
    local json _bp1=$((bp+1))
    json=$(jq -S --argjson bp1 "$_bp1" \
        "[.[:\$bp1][] | .content |= (if type == \"string\" then [{type:\"text\", text:.}] else . end)]" \
        <<< "$MESSAGES")
    printf '%s' "$json"
}

# ── Tools JSON with optional cache_control (BP2) ──
_pe_assemble_tools() {
    local _emit_markers="${1:-0}"
    local _prebuilt="${2:-}"
    local tools_json="${_prebuilt:-$(build_tools_json)}"
    if [[ "$_emit_markers" == "1" ]]; then
        jq -S --argjson CM "$CACHE_MARKER_JSON" \
            'if length > 0 then .[-1] += $CM else . end' <<< "$tools_json"
    else
        printf '%s' "$tools_json"
    fi
}

# ── ARG_MAX-safe jq wrapper ──
# Temp-files large JSON/text values to avoid "Argument list too long" errors.
# $1: tmp_dir (already created by caller)
# $2: jq filter string
# $3+: alternating flagged args:
#      --json <name> <content>   → --slurpfile $name file  (access: $name[0])
#      --str  <name> <content>   → --rawfile  $name file  (access: $name)
#      --arg  <name> <value>     → --arg      $name "$value"  (small values)
#      --argjson <name> <value>  → --argjson  $name "$value"  (small values)
# Output: jq result on stdout
_jq_tempfile() {
    local _tmp="$1" _filter="$2"
    shift 2
    local _jq_args=()
    while (( $# > 0 )); do
        case "$1" in
            --json)
                printf '%s' "$3" > "$_tmp/$2.json"
                _jq_args+=(--slurpfile "$2" "$_tmp/$2.json")
                shift 3 ;;
            --str)
                printf '%s' "$3" > "$_tmp/$2.txt"
                _jq_args+=(--rawfile "$2" "$_tmp/$2.txt")
                shift 3 ;;
            --arg)
                _jq_args+=(--arg "$2" "$3")
                shift 3 ;;
            --argjson)
                _jq_args+=(--argjson "$2" "$3")
                shift 3 ;;
            *) shift ;;
        esac
    done
    jq -n "${_jq_args[@]}" "$_filter"
}

# ═══════════════════════════════════════════════════════════════════════════
# PROTOCOL CONVERSION — Anthropic ↔ OpenAI Chat Completions API
# ═══════════════════════════════════════════════════════════════════════════
# Internal format stays Anthropic. These functions convert at the API boundary.

# Full Anthropic request body → OpenAI request body.
# Single jq invocation for efficiency with large message histories.
_proto_convert_request() {
    jq -c '
    def _oa_user:
      if (.content | type) == "string" then {role: "user", content: .content}
      else [.content[] |
        if .type == "tool_result" then
          {role: "tool", tool_call_id: .tool_use_id, content: .content}
        elif .type == "text" then
          {role: "user", content: .text}
        else empty end
      ] end;
    def _oa_assistant:
      {role: "assistant"} +
      (([.content[]? | select(.type == "text") | .text] | join("")) as $txt |
        if $txt != "" then {content: $txt} else {} end) +
      (([.content[]? | select(.type == "thinking") | .thinking] | join("\n")) as $rc |
        if $rc != "" then {reasoning_content: $rc} else {} end) +
      ([.content[]? | select(.type == "tool_use") |
        {id: .id, type: "function",
         function: {name: .name, arguments: (.input | tostring)}}] as $tc |
        if $tc != [] then {tool_calls: $tc} else {} end);
    def _oa_msgs:
      [.[] | if .role == "user" then _oa_user
             elif .role == "assistant" then _oa_assistant
             else . end] | flatten;
    def _sys_text:
      ((.system // "") | if type == "string" then (try fromjson catch .) else . end) as $sys |
      if ($sys | type) == "array" then
        [$sys[] | .text // ""] | join("\n\n")
      elif ($sys | type) == "string" then $sys
      else "" end;
    {
      model: .model,
      max_completion_tokens: .max_tokens,
      messages: (
        (if _sys_text != "" then [{role: "system", content: _sys_text}] else [] end) +
        (.messages | _oa_msgs)
      ),
      tools: ([.tools[]? |
        {type: "function",
         function: {name: .name, description: (.description // ""),
         parameters: .input_schema}}]),
      stream: .stream
    }
    '
}

# Convert non-streaming OpenAI response to Anthropic-style content blocks.
# Used by sub-agent path (_call_agent_core) to normalize API response.
_proto_convert_response() {
    jq -c '
    def _from_content:
      if .choices[0].message.content then
        [{type: "text", text: .choices[0].message.content}]
      else [] end;
    def _from_tool_calls:
      [.choices[0].message.tool_calls[]? |
        {type: "tool_use", id: .id, name: .function.name,
         input: (.function.arguments | fromjson)}];
    def _from_reasoning:
      if .choices[0].message.reasoning_content then
        [{type: "thinking", thinking: .choices[0].message.reasoning_content}]
      else [] end;
    {
      content: (_from_reasoning + _from_content + _from_tool_calls),
      stop_reason: (if .choices[0].finish_reason == "stop" then "end_turn"
                    elif .choices[0].finish_reason == "tool_calls" then "tool_use"
                    elif .choices[0].finish_reason == "length" then "max_tokens"
                    else "end_turn" end),
      usage: {input_tokens: (.usage.prompt_tokens // 0),
              output_tokens: (.usage.completion_tokens // 0)}
    }
    '
}

# ═══════════════════════════════════════════════════════════════════════════
# UNIFIED DETERMINISTIC CONSTRUCTOR
# ═══════════════════════════════════════════════════════════════════════════
# Assembles the full request body with byte-identical prefixes across calls.
# Args: model max_tokens thinking_budget stream bp emit_markers
# bp and emit_markers are computed in call_api_nonstreaming (main shell).
_pe_assemble_request() {
    local model="$1" max_tokens="$2" tb="$3"
    local stream="${4:-true}"
    local _bp="${5:--1}"
    local _emit_markers="${6:-0}"
    local _sys_json="${7:-}"
    local _tools_json="${8:-}"

    # 1. Sync bp + ensure segment cache is fresh
    MSG_BP=${_bp:--1}
    if (( MSG_SEGMENTS_DIRTY )); then
        _msg_segments_refresh_from_messages   # 1 jq (only when dirty — rare)
    fi
    # 2. Get dyn_msg (hash-cached, 0 jq when unchanged, global to avoid $() subshell)
    _tm "req_assem_start"
    _pe_assemble_context
    _tm "req_assem_ctx_done"
    local dyn_msg="$_PE_DYN_MSG"

    # 3. Hook context injection (conditional, 1 small jq)
    if [[ -n "${_HOOK_CONTEXT_BUFFER:-}" ]]; then
        dyn_msg=$(jq -c --arg hc "$_HOOK_CONTEXT_BUFFER" \
            '.content += [{type:"text",text:$hc}]' <<< "$dyn_msg")
    fi

    # 4. Cache marker on msg_prefix (conditional, 1 small jq)
    #    sys/tools markers already injected by _pe_assemble_system / _pe_assemble_tools
    local _pfx_inner="$MSG_PREFIX_INNER"
    if [[ "$_emit_markers" == "1" ]] && (( MSG_BP >= 0 )); then
        local _pfx_array; _pfx_array=$(jq -c --argjson pos "$MSG_BP" --argjson CM "$CACHE_MARKER_JSON" \
            'if .[$pos] then .[$pos].content[-1] += $CM else . end' <<< "[${MSG_PREFIX_INNER}]")
        if [[ "$_pfx_array" != '[]' ]]; then
            _pfx_inner="${_pfx_array:1:$(( ${#_pfx_array} - 2 ))}"
        else
            _pfx_inner=''
        fi
    fi

    # 5. Pure bash: assemble messages array
    local _msgs="["

    if [[ -n "$_pfx_inner" ]]; then
        _msgs+="${_pfx_inner},"
    fi

    _msgs+="$dyn_msg"

    if [[ -n "$MSG_TAIL_INNER" ]]; then
        _msgs+=",${MSG_TAIL_INNER}"
    fi
    _msgs+="]"

    # 6. Pure bash: assemble final request body ★ 0 jq ★
    local _model_esc="${model//\\/\\\\}"
    _model_esc="${_model_esc//\"/\\\"}"

    local _thinking
    if (( tb > 0 )); then
        _thinking='{"budget_tokens":'"$tb"',"type":"enabled"}'
    else
        _thinking='{"type":"disabled"}'
    fi

    local _body
    printf -v _body \
        '{"model":"%s","max_tokens":%s,"tools":%s,"system":%s,"messages":%s,"stream":%s,"thinking":%s}\n' \
        "$_model_esc" "$max_tokens" "$_tools_json" "$_sys_json" "$_msgs" "$stream" "$_thinking"

    # 7. Protocol conversion (conditional, 1 jq on full body — only for OpenAI)
    if [[ "$(_prof_get_field protocol)" == "openai" ]]; then
        local _tmp; _tmp=$(_mktemp_dir /tmp/bashagt_det.XXXXXX)
        printf '%s' "$_body" > "$_tmp/body.json"
        _proto_convert_request < "$_tmp/body.json"
        local _rc=$?
        rm -rf "$_tmp"
        return $_rc
    else
        printf '%s' "$_body"
    fi
}

# ============================================================================
# SECTION 6: API Client with SSE Streaming
# ============================================================================

# Build an API request body JSON string.
# Large values are temp-filed internally to avoid ARG_MAX (128KB per-arg limit).
# Arguments: model max_tokens system_prompt messages tools thinking_budget stream
#   stream: "true" or "false" (JSON boolean)
# Output: JSON request body on stdout
build_request_body() {
    local model="$1" max_tokens="$2" system="$3" messages="$4" tools="$5" tb="$6" stream="${7:-true}" _sys_json="${8:-0}"
    local _tmp
    _tmp=$(_mktemp_dir /tmp/bashagt_req.XXXXXX)
    if [[ -z "$_tmp" ]] || [[ ! -d "$_tmp" ]]; then
        log "ERROR: mktemp failed -- cannot create temp directory for request body"
        return 2
    fi
    local _body_file="$_tmp/body.json"
    local _sys_flag="--str" _sys_jq='$system'
    [[ "$_sys_json" == "1" ]] && { _sys_flag="--json"; _sys_jq='$system[0]'; }
    if (( tb > 0 )); then
        _jq_tempfile "$_tmp" \
            "{model:\$model, max_tokens:\$max_tokens, tools:\$tools[0],
              system:$_sys_jq, messages:\$messages[0], stream:\$stream,
              thinking:{budget_tokens:\$thinking_budget, type:\"enabled\"}}" \
            --arg model "$model" \
            --argjson max_tokens "$max_tokens" \
            --json tools "$tools" \
            $_sys_flag system "$system" \
            --json messages "$messages" \
            --argjson stream "$stream" \
            --argjson thinking_budget "$tb" > "$_body_file"
    else
        _jq_tempfile "$_tmp" \
            "{model:\$model, max_tokens:\$max_tokens, tools:\$tools[0],
              system:$_sys_jq, messages:\$messages[0], stream:\$stream,
              thinking:{type:\"disabled\"}}" \
            --arg model "$model" \
            --argjson max_tokens "$max_tokens" \
            --json tools "$tools" \
            $_sys_flag system "$system" \
            --json messages "$messages" \
            --argjson stream "$stream" > "$_body_file"
    fi
    local rc=$?
    if [[ "$(_prof_get_field protocol)" == "openai" ]]; then
        _proto_convert_request < "$_body_file"
    else
        cat "$_body_file"
    fi
    rm -rf "$_tmp"
    return $rc
}

# Call the API with streaming. Stores reconstructed content and tool uses in temp files.
# Arguments: none (reads request body from stdin)
# Outputs: text content to stdout, thinking to stderr
# Sets global: STOP_REASON, needs retry, etc. via temp files

# Non-streaming API call: http_post (blocking) + async thinking timer.
# Replaces SSE streaming — all text arrives at once, then format_text_stream
# handles progressive display. UI unchanged: same spinner, status, elapsed.
call_api_nonstreaming() {
    _tm "call_api_start"
    local tmp_dir
    tmp_dir=$(_mktemp_dir /tmp/bashagt_ns.XXXXXX)
    if [[ -z "$tmp_dir" ]] || [[ ! -d "$tmp_dir" ]]; then
        log "ERROR: mktemp failed — cannot create temp directory"
        return 2
    fi

    # Read profile fields directly (avoids 8 $() subshell forks)
    local _ap_model _ap_url _ap_proto _ap_auth_h _ap_auth_px _ap_key _ap_mt _ap_tb
    _ap_model="${_PROF_MODEL:-$BASHAGT_MODEL}"
    _ap_url="${_PROF_API_URL:-$BASHAGT_API_URL}"
    _ap_proto="${_PROF_PROTOCOL:-${BASHAGT_PROTOCOL:-anthropic}}"
    _ap_auth_h="${_PROF_AUTH_HEADER:-$BASHAGT_AUTH_HEADER}"
    _ap_auth_px="${_PROF_AUTH_PREFIX:-$BASHAGT_AUTH_PREFIX}"
    _ap_key="${_PROF_API_KEY:-$BASHAGT_API_KEY}"
    _ap_mt="${_PROF_MAX_TOKENS:-$BASHAGT_MAX_TOKENS}"
    _ap_tb="${_PROF_THINKING_BUDGET:-$BASHAGT_THINKING_BUDGET}"

    # Auto-reload external sources if changed on disk
    _tm "call_api_prof_done"
    load_bashagt_md
    _reload_skills_if_stale
    _tm "call_api_reload_done"

    # ── Start spinner early — covers bp/tools/request computation (~300-500ms) ──
    local show_thoughts="${BASHAGT_SHOW_THINKING:-status}"
    local _start_ts _elapsed=0 _icon _spinner_now
    _start_ts=$(_timestamp_ms)
    _icon=$(ui_spinner)

    local _stm_fd=${_BYPASS_FD:-1}
    status_begin "$(_build_status "$_icon" "Thinking..." 0 0 0)"
    _stream_kv status_begin icon "$_icon" label "Thinking..." elapsed_str "0s" >&$_stm_fd

    # ── Compute bp from formula each call (per-turn advance) ──
    local _msg_bp
    _msg_bp=$(( MSG_COUNT - ${BASHAGT_CACHE_MSG_TAIL:-2} - 1 ))
    (( _msg_bp < 0 )) && _msg_bp=-1
    # Fast path: small total → extend bp to end (tail=0)
    if (( _msg_bp >= 0 && ${#MESSAGES} < 4096 )); then
        _msg_bp=$(( MSG_COUNT - 1 ))
    fi

    # Adjust breakpoint to avoid splitting tool_use/tool_result pairs.
    # Check the bp message (last in PREFIX) for tool_use — uses small cached segment.
    if (( _msg_bp >= 0 && _msg_bp < MSG_COUNT - 1 )); then
        local _bp_has_tu
        _bp_has_tu=$(jq -r --argjson i "$_msg_bp" \
            'any(.[$i].content[]?; .type=="tool_use")' <<< "[${MSG_PREFIX_INNER}]" 2>/dev/null || echo false)
        if [[ "$_bp_has_tu" == "true" ]]; then
            _msg_bp=$((_msg_bp - 1))
        fi
    fi
    # Advance segment cache if bp changed
    if (( _msg_bp != MSG_BP )); then
        local _bp_shift=$((_msg_bp - MSG_BP))
        if (( _bp_shift == 1 && MSG_BP >= 0 )); then
            _msg_segments_advance_bp "$_msg_bp"   # incremental (small jq on TAIL)
        else
            MSG_SEGMENTS_DIRTY=1                   # full refresh (bp big jump)
            MSG_BP=$_msg_bp
        fi
    fi
    _spin_tick
    status_update "$(_build_status "$_SPIN_FRAME" "Thinking..." 0 0 0)"
    _tm "call_api_bp_done"

    # ── Marker emission decision in MAIN shell (subshell _CACHE_PROBE writes lost) ──
    local _emit_markers=0
    if [[ "${_CACHE_PROBE[state]:-disabled}" != "disabled" ]]; then
        _pe_cache_active && _emit_markers=1 || _emit_markers=0
    fi

    local request_body _max_tok_val _sys_json
    _max_tok_val="${EFFECTIVE_MAX_TOKENS:-$_ap_mt}"
    # System prompt cache: rebuild only when invalidated (BASHAGT.md/skills change)
    if [[ -n "$SYS_JSON_CACHE" ]]; then
        _sys_json="$SYS_JSON_CACHE"
    else
        _sys_json=$(_pe_assemble_system "$_emit_markers")
        SYS_JSON_CACHE="$_sys_json"
    fi
    # Read tools: use cache directly when valid (avoids temp file I/O)
    local _tools_json _epoch; _epoch=${EPOCHSECONDS:-$(date +%s)}
    if [[ -n "$TOOLS_JSON_CACHE" ]] && (( _epoch - TOOLS_CACHE_EPOCH < 300 )); then
        _tools_json="$TOOLS_JSON_CACHE"
    else
        local _tools_tmp; _tools_tmp=$(_mktemp_file)
        build_tools_json > "$_tools_tmp"
        _tools_json=$(<"$_tools_tmp"); rm -f "$_tools_tmp"
    fi
    if [[ "$_emit_markers" == "1" ]]; then
        _tools_json=$(_pe_assemble_tools "$_emit_markers" "$_tools_json")
    fi
    # else: _tools_json already has the cached value, _pe_assemble_tools is pure passthrough
    _tm "call_api_sys_tools_done"
    request_body=$(_pe_assemble_request "$_ap_model" "$_max_tok_val" "$_max_tok_val" "false" "$_msg_bp" "$_emit_markers" "$_sys_json" "$_tools_json")
    _tm "call_api_request_done"
    _HOOK_CONTEXT_BUFFER=""  # consumed — clear for next cycle

    # bp finalized — no advance state needed (computed fresh each call)

    log "DEBUG: [API] call_api_nonstreaming: model=$_ap_model max_tok=$_max_tok_val msgs=$MSG_COUNT thinking=$_ap_tb"

    _spin_tick
    status_update "$(_build_status "$_SPIN_FRAME" "Thinking..." 0 0 0)"

    # ── Background: http_post (blocking) → response file ──
    local _out_file="$tmp_dir/response.json"
    local _av_header=()
    [[ "$_ap_proto" != "openai" ]] && _av_header=(--header "anthropic-version: 2023-06-01")

    _tm "call_api_http_dispatch"
    http_post "$_ap_url" "$_out_file" \
        --connect-timeout "${BASHAGT_CONNECT_TIMEOUT:-10}" --max-time 900 \
        "${_av_header[@]}" \
        --header "content-type: application/json" \
        --auth-header "$_ap_auth_h" --auth-value "${_ap_auth_px}${_ap_key}" \
        --body "$request_body" \
        --err-file "$tmp_dir/curl_err" &
    local _api_pid=$!
    _proc_register "$_api_pid" "api" "http_post"

    # ── Foreground: wait for API with Esc/Ctrl-C interrupt ──
    # Spinner animation is handled by async ticker (_spinner_ticker_start)
    while kill -0 $_api_pid 2>/dev/null; do
        _spin_sleep 0.1 || { _proc_kill "$_api_pid"; break; }
    done
    wait $_api_pid
    local _rc=$?

    _elapsed=$(( $(_timestamp_ms) - _start_ts ))
    (( _elapsed < 0 )) && _elapsed=0

    # ── Error handling ──
    if [[ $_rc -ne 0 ]]; then
        # Suppress error display if interrupted by Esc/Ctrl-C —
        # the I4 handler in run_turn will show "Turn interrupted" instead.
        if [[ "${_bagt_interrupted:-0}" != "1" ]]; then
            local _curl_err _err_msg=""
            _curl_err=$(head -5 "$tmp_dir/curl_err" 2>/dev/null || true)
            if [[ -s "$_out_file" ]]; then
                _err_msg=$(jq -r '.error.message // empty' "$_out_file" 2>/dev/null || echo "")
            fi
            [[ -z "$_err_msg" ]] && _err_msg="HTTP error (rc=$_rc)"
            [[ -n "$_curl_err" ]] && log "curl stderr: $_curl_err"
            log "API error: $_err_msg"

            status_done "think-error ($(ui_time $_elapsed))"
            _ui_time $_elapsed
            _stream_kv status_done label "think-error" elapsed_str "$_UI_TIME" >&$_stm_fd
        else
            # Interrupted: must send status_done to stop renderer self-animating timer
            _ui_time $_elapsed
            _stream_kv status_done label "think-interrupted" elapsed_str "$_UI_TIME" >&$_stm_fd
        fi

        # Try to salvage partial token counts from truncated response body.
        # curl writes body to $_out_file as it arrives; grep can extract
        # "input_tokens":N / "output_tokens":N even from broken JSON.
        local _pt_in=0 _pt_out=0
        if [[ -s "$_out_file" ]]; then
            _pt_in=$(grep -oE '"input_tokens"[[:space:]]*:[[:space:]]*[0-9]+' "$_out_file" 2>/dev/null | head -1 | grep -oE '[0-9]+' || echo 0)
            _pt_out=$(grep -oE '"output_tokens"[[:space:]]*:[[:space:]]*[0-9]+' "$_out_file" 2>/dev/null | head -1 | grep -oE '[0-9]+' || echo 0)
        fi
        TURN_INPUT_TOKENS="${_pt_in:-0}"
        TURN_OUTPUT_TOKENS="${_pt_out:-0}"
        CONTENT_BLOCKS='[]'; STOP_REASON='end_turn'
        rm -rf "$tmp_dir"; return 3
    fi

    # ── Parse response ──
    local response; response=$(< "$_out_file")

    # Convert OpenAI response to internal Anthropic format
    if [[ "$_ap_proto" == "openai" ]]; then
        response=$(_proto_convert_response <<< "$response")
    fi

    # Extract all fields from response in one jq pass (8→1 parse)
    local _rp
    _rp=$(jq -c '{
      content:   (.content // []),
      stop:      (.stop_reason // "end_turn"),
      tok_in:    (.usage.input_tokens // 0),
      tok_out:   (.usage.output_tokens // 0),
      cache_rd:  (.usage.cache_read_input_tokens // 0),
      cache_cr:  (.usage.cache_creation_input_tokens // 0),
      thinking:  ([.content[]? | select(.type=="thinking") | .thinking] | join("\n")),
      text:      ([.content[]? | select(.type=="text") | .text] | join(""))
    }' <<< "$response" 2>/dev/null)

    # Batch extract 6 single-line fields from _rp (1 jq instead of 6)
    local _cache_read _cache_create _rp_batch
    _rp_batch=$(jq -r '
      ((.content // []) | tostring),
      (.stop // "end_turn"),
      (.tok_in // 0),
      (.tok_out // 0),
      (.cache_rd // 0),
      (.cache_cr // 0)
    ' <<< "$_rp" 2>/dev/null; true) || true
    {
        IFS= read -r CONTENT_BLOCKS
        IFS= read -r STOP_REASON
        IFS= read -r TURN_INPUT_TOKENS
        IFS= read -r TURN_OUTPUT_TOKENS
        IFS= read -r _cache_read
        IFS= read -r _cache_create
    } <<< "$_rp_batch" || true
    _cache_probe_feedback "$_cache_read" "$_cache_create" "$TURN_INPUT_TOKENS"

    # ── Defer status_done to format first token (smoother transition) ──
    _FMT_DONE_LABEL="think-done"
    _FMT_START_TS="$_start_ts"
    _FMT_DONE_ITOK="${TURN_INPUT_TOKENS:-0}"
    _FMT_DONE_OTOK="${TURN_OUTPUT_TOKENS:-0}"

    # DEBUG

    # ── Display thinking content (if any) ──
    local thinking_text raw_text
    thinking_text=$(jq -r '.thinking' <<< "$_rp" 2>/dev/null)
    if [[ -n "$thinking_text" && "$thinking_text" != "null" && "$show_thoughts" == "full" ]]; then
        if [[ "${BASHAGT_OE_RAW:-0}" == "1" ]] || [[ "${BASHAGT_STREAM_MODE:-0}" == "1" ]]; then
            _stream_emit "thinking" "$(jq -nc --arg c "$thinking_text" '{content: $c}')" >&$_stm_fd
        else
            printf '%s\n' "$thinking_text" >&2
        fi
    fi

    # ── Format and display text ──
    raw_text=$(jq -r '.text' <<< "$_rp" 2>/dev/null)
    if [[ -n "$raw_text" && "$raw_text" != "null" ]]; then
        format_text_stream "$raw_text" "${#raw_text}" "$TERM_WIDTH" "8" "" || {
            # Fallback: emit deferred status_done + raw text
            if [[ -n "${_FMT_DONE_LABEL:-}" ]]; then
                _ui_time $(( $(_timestamp_ms) - _FMT_START_TS )); local _ela="$_UI_TIME"
                _stream_kv status_done \
                    label "$_FMT_DONE_LABEL" elapsed_str "$_ela" \
                    tokens_in "${_FMT_DONE_ITOK:-0}" tokens_out "${_FMT_DONE_OTOK:-0}" >&$_stm_fd
                _FMT_DONE_LABEL=""
            fi
            while IFS= read -r _line; do
                _stream_text "$_line"
            done <<< "$raw_text"
        }
    fi
    # Safety: if format_text_stream didn't consume the label (no text output), emit now
    if [[ -n "${_FMT_DONE_LABEL:-}" ]]; then
        local _ela; _ela=$(ui_time $(( $(_timestamp_ms) - _FMT_START_TS )))
        _stream_kv status_done \
            label "$_FMT_DONE_LABEL" elapsed_str "$_ela" \
            tokens_in "${_FMT_DONE_ITOK:-0}" tokens_out "${_FMT_DONE_OTOK:-0}" >&$_stm_fd
        _FMT_DONE_LABEL=""
    fi

    log "DEBUG: [API] call_api_nonstreaming: done stop=$STOP_REASON tok={in:$TURN_INPUT_TOKENS,out:$TURN_OUTPUT_TOKENS} elapsed=${_elapsed}ms"

    rm -rf "$tmp_dir"
    return 0
}

# format tool result for API
format_tool_result() {
    local tool_use_id="$1" content="$2" is_error="${3:-false}"
    printf '%s' "$content" | jq -Rs \
        --arg tid "$tool_use_id" \
        --argjson err "$is_error" \
        '{type:"tool_result",tool_use_id:$tid,content:.,is_error:$err}'
}

# ============================================================================
# SECTION 6a: HTTP/SSE Foundation Module
# ============================================================================
# Single curl entry-point. All HTTP communication MUST go through these functions.
#
# Exit code contract:
#   0 = success (HTTP 2xx)
#   1 = connect timeout
#   2 = total timeout
#   3 = HTTP error (4xx/5xx)
#   4 = DNS / network unreachable
#   5 = TLS error
#   6 = curl not found
#
# Body safety: --body values are ALWAYS written to temp file and passed via
# -d @file (not -d "$var") to prevent ARG_MAX explosion on large payloads.

# ── http_retry <max> <base_ms> <max_ms> <func> [args...] ──
# Exponential backoff + full jitter. Retries on codes 1 (connect timeout)
# and 4 (DNS/connect refused). Does NOT retry on 3 (HTTP error) or 5 (TLS).
http_retry() {
    local _max="$1" _base_ms="$2" _max_ms="$3" _retry=0 _delay _rc
    shift 3
    while true; do
        "$@" ; _rc=$?
        case "$_rc" in
            1|4)  # connect timeout / DNS — retryable
                _retry=$((_retry + 1))
                (( _retry > _max )) && return $_rc
                _delay=$(( (_base_ms * (1 << (_retry - 1))) < _max_ms ? (_base_ms * (1 << (_retry - 1))) : _max_ms ))
                _delay=$(( RANDOM % (_delay + 1) ))
                _spin_sleep "$(awk "BEGIN {printf \"%.3f\", $_delay / 1000}")"
                ;;
            *) return $_rc ;;
        esac
    done
}

# ── Internal: map curl exit code + HTTP status to bashagt exit code ──
_http_map_exit() {
    local _curl_rc="$1" _http_status="${2:-0}" _got_data="${3:-1}"
    case "$_curl_rc" in
        0)  case "$_http_status" in
                2*) return 0 ;;   # 2xx success
                4*|5*) return 3 ;; # HTTP error
                *) return 0 ;;
            esac ;;
        6)  return 4 ;;   # DNS / host not found
        7)  return 4 ;;   # connect refused
        28) [[ "${_got_data:-0}" -ne 0 ]] && return 2 || return 1 ;;  # timeout: data→total, no-data→connect
        35|60) return 5 ;; # TLS
        *) return 6 ;;
    esac
}

# ── http_request <method> <url> <out_file> [named args...] ──
# Named args:
#   --header "Key: Value"        (repeatable)
#   --body "data"                (POST body — auto temp-filed)
#   --connect-timeout N          (default: $BASHAGT_CONNECT_TIMEOUT or 10)
#   --max-time N                 (default: 900)
#   --no-redirect                (disable -L)
#   --auth-header "Header-Name"  (e.g. "Authorization")
#   --auth-value "token"         (combined: "Header-Name: token")
#   --query-param key value      (repeatable, jq @uri encoded)
#   --err-file path              (stderr capture file, default /dev/null)
# Output: response body → out_file, status line → ${out_file}.status
http_request() {
    local _method="$1" _url="$2" _out_file="$3"; shift 3

    local _ct="${BASHAGT_CONNECT_TIMEOUT:-10}" _mt=900 _follow=1
    local _body="" _body_file="" _err_file="/dev/null"
    local _hdr_args=() _qp_keys=() _qp_vals=()

    while (($# > 0)); do
        case "$1" in
            --header)         _hdr_args+=(-H "$2"); shift 2 ;;
            --body)           _body="$2"; shift 2 ;;
            --connect-timeout) _ct="$2"; shift 2 ;;
            --max-time)       _mt="$2"; shift 2 ;;
            --no-redirect)    _follow=0; shift ;;
            --auth-header)    _auth_hdr="$2"; shift 2 ;;
            --auth-value)     _auth_val="$2"; shift 2 ;;
            --query-param)    _qp_keys+=("$2"); _qp_vals+=("$3"); shift 3 ;;
            --err-file)       _err_file="$2"; shift 2 ;;
            *) shift ;;
        esac
    done

    # Build URL with query params (jq @uri for safe encoding incl. CJK)
    local _i _sep="?"
    for ((_i=0; _i<${#_qp_keys[@]}; _i++)); do
        local _enc; _enc=$(jq -rn --arg v "${_qp_vals[$_i]}" '$v | @uri')
        _url+="${_sep}${_qp_keys[$_i]}=${_enc}"
        _sep="&"
    done

    # Write body to temp file (prevents ARG_MAX explosion)
    if [[ -n "$_body" ]]; then
        _body_file=$(_mktemp_file /tmp/bashagt_body.XXXXXX)
        printf '%s' "$_body" > "$_body_file"
    fi

    log "DEBUG: [API] http_request: $_method $_url ct=${_ct}s mt=${_mt}s"

    local curl_args=(curl -s -X "$_method")
    curl_args+=(--connect-timeout "$_ct" --max-time "$_mt")
    # Proxy support
    if [[ -n "${BASHAGT_PROXY_URL:-}" ]]; then
        curl_args+=(--proxy "$BASHAGT_PROXY_URL")
        if [[ -n "${BASHAGT_PROXY_USER:-}" && -n "${BASHAGT_PROXY_PASS:-}" ]]; then
            curl_args+=(--proxy-user "${BASHAGT_PROXY_USER}:${BASHAGT_PROXY_PASS}")
        fi
        curl_args+=(--noproxy "${BASHAGT_PROXY_NOPROXY:-localhost,127.0.0.1,::1}")
    fi
    curl_args+=(-o "$_out_file")
    curl_args+=(-w '%{http_code}|%{time_total}|%{size_download}')
    (( _follow )) && curl_args+=(-L)
    [[ -n "${_auth_hdr:-}" && -n "${_auth_val:-}" ]] && curl_args+=(-H "${_auth_hdr}: ${_auth_val}")
    for _h in "${_hdr_args[@]}"; do curl_args+=("$_h"); done
    [[ -n "$_body_file" ]] && curl_args+=(-d "@${_body_file}")
    curl_args+=("$_url")

    set +e
    "${curl_args[@]}" > "${_out_file}.status" 2>"$_err_file"
    local _curl_rc=$?
    set -e

    [[ -n "$_body_file" ]] && rm -f "$_body_file"

    # Parse HTTP status from -w output
    local _http_status=0 _status_raw
    _status_raw=$(head -1 "${_out_file}.status" 2>/dev/null)
    _http_status="${_status_raw%%|*}"
    rm -f "${_out_file}.status"

    log "DEBUG: [API] http_request: done rc=$_curl_rc http=$_http_status"

    _http_map_exit "$_curl_rc" "$_http_status" 1
}

# ── http_get <url> <out_file> [named args...] ──
http_get() {
    http_request "GET" "$@"
}

# ── http_post <url> <out_file> [named args...] ──
http_post() {
    http_request "POST" "$@"
}

# ── http_sse_connect <url> <callback_func> [named args...] ──
# SSE streaming client. Runs curl -sN, pipes lines through SSE parser.
# Callback invoked as: $callback_func "event_type" "data" "id" "retry_ms"
# Shared SSE line parser — reads from calling scope's _sse_data/_sse_event/
# _sse_id/_sse_retry/_sse_got_data/_callback variables via bash dynamic scoping.
# Called from http_sse_connect's three polling/drain/fallback loops.
_sse_parse_line() {
    local _line="$1"
    case "$_line" in
        '')  if [[ -n "$_sse_data" ]]; then
                _sse_got_data=1
                _sse_data="${_sse_data%$'\n'}"
                "$_callback" "${_sse_event:-message}" "$_sse_data" "$_sse_id" "$_sse_retry"
             fi
             _sse_event="" _sse_data="" ;;
        event:*) _sse_event="${_line#event:}"; _sse_event="${_sse_event# }" ;;
        data:*)  local _d="${_line#data:}"; _d="${_d# }"; _sse_data+="${_d}"$'\n' ;;
        id:*)    _sse_id="${_line#id:}"; _sse_id="${_sse_id# }" ;;
        retry:*) _sse_retry="${_line#retry:}"; _sse_retry="${_sse_retry# }" ;;
        :*) ;;
    esac
}

# Uses shopt -s lastpipe so callback runs in parent shell (globals survive).
# Named args: same as http_request minus --no-redirect (always follows).
# Additional: --err-file for stderr diagnostics.
# Returns: 0=clean close with events, 1=no events received, 2=curl error
http_sse_connect() {
    local _url="$1" _callback="$2"; shift 2

    local _ct="${BASHAGT_CONNECT_TIMEOUT:-10}" _mt=0
    local _body="" _body_file="" _err_file="/dev/null"
    local _hdr_args=()

    while (($# > 0)); do
        case "$1" in
            --header)         _hdr_args+=(-H "$2"); shift 2 ;;
            --body)           _body="$2"; shift 2 ;;
            --connect-timeout) _ct="$2"; shift 2 ;;
            --max-time)       _mt="$2"; shift 2 ;;
            --auth-header)    _auth_hdr="$2"; shift 2 ;;
            --auth-value)     _auth_val="$2"; shift 2 ;;
            --spin-callback)  _sse_spin_cb="$2"; shift 2 ;;
            --err-file)       _err_file="$2"; shift 2 ;;
            *) shift ;;
        esac
    done

    [[ -n "$_body" ]] && { _body_file=$(_mktemp_file /tmp/bashagt_body.XXXXXX); printf '%s' "$_body" > "$_body_file"; }

    log "DEBUG: [API] http_sse_connect: $_url ct=${_ct}s"

    local curl_args=(curl -sN -L --connect-timeout "$_ct")
    [[ "$_mt" -gt 0 ]] && curl_args+=(--max-time "$_mt")
    # Proxy support
    if [[ -n "${BASHAGT_PROXY_URL:-}" ]]; then
        curl_args+=(--proxy "$BASHAGT_PROXY_URL")
        if [[ -n "${BASHAGT_PROXY_USER:-}" && -n "${BASHAGT_PROXY_PASS:-}" ]]; then
            curl_args+=(--proxy-user "${BASHAGT_PROXY_USER}:${BASHAGT_PROXY_PASS}")
        fi
        curl_args+=(--noproxy "${BASHAGT_PROXY_NOPROXY:-localhost,127.0.0.1,::1}")
    fi
    curl_args+=(-X POST)
    [[ -n "${_auth_hdr:-}" && -n "${_auth_val:-}" ]] && curl_args+=(-H "${_auth_hdr}: ${_auth_val}")
    for _h in "${_hdr_args[@]}"; do curl_args+=("$_h"); done
    [[ -n "$_body_file" ]] && curl_args+=(-d "@${_body_file}")
    curl_args+=("$_url")

    local _sse_event="" _sse_data="" _sse_id="" _sse_retry=""
    local _sse_got_data=0 _sse_first_line=""

    # ── SSE streaming with interrupt support ──
    # Use background curl → FIFO → polling loop so we can read stdin
    # (Esc key) and check the soft-interrupt flag (Ctrl-C via INT trap)
    # while consuming SSE lines.  Falls back to original pipe if mkfifo fails.
    local _sse_fifo; _sse_fifo=$(_mktemp_u /tmp/bashagt_sse.XXXXXX)
    if mkfifo "$_sse_fifo" 2>/dev/null; then
        # Background curl → FIFO
        set +e
        "${curl_args[@]}" 2>"$_err_file" > "$_sse_fifo" &
        local _curl_pid=$!
        _BAGT_CURL_PID=$_curl_pid
        _proc_register "$_curl_pid" "api" "http_sse"
        set -e

        # Open FIFO read-end on a dynamic fd (avoids collision with fd 8/9)
        local _sse_fd
        exec {_sse_fd}< "$_sse_fifo"
        log "DEBUG: [SSE] dyn_fd=$_sse_fd fd8=$( [[ -e /proc/self/fd/8 ]] && echo ok || echo MISSING ) fd9=$( [[ -e /proc/self/fd/9 ]] && echo ok || echo MISSING )"

        # Polling loop: keyboard + SSE FIFO
        while kill -0 $_curl_pid 2>/dev/null; do
            _interrupted && { _proc_kill "$_curl_pid"; break; }
            _poll_esc || { _proc_kill "$_curl_pid"; break; }
            [[ -n "${_sse_spin_cb:-}" ]] && { $_sse_spin_cb || true; }

            # Read one SSE line from FIFO (50ms timeout; OS pipe buffer prevents data loss)
            if IFS= read -r -t 0.05 -u $_sse_fd _line 2>/dev/null; then
                [[ -z "$_sse_first_line" && -n "$_line" ]] && _sse_first_line="$_line"
                _sse_parse_line "$_line"
            fi
        done

        # Drain remaining FIFO data after curl exits.
        # Curl may exit while the callback is still running or between
        # a data-line read and the next kill -0 check.  Any unread data
        # left in the kernel pipe buffer is lost when we close the fd.
        while IFS= read -r -t 0.01 -u $_sse_fd _line 2>/dev/null; do
            _sse_parse_line "$_line"
        done

        # Flush accumulated _sse_data whose separator empty-line never
        # arrived (curl killed mid-write, crash, or pipe buffer overflow)
        if [[ -n "$_sse_data" ]]; then
            _sse_got_data=1
            _sse_data="${_sse_data%$'\n'}"
            "$_callback" "${_sse_event:-message}" "$_sse_data" "$_sse_id" "$_sse_retry"
        fi

        exec {_sse_fd}<&-
        rm -f "$_sse_fifo"
        wait $_curl_pid 2>/dev/null || true
        _BAGT_CURL_PID=""
    else
        # FIFO failed — fall back to original pipe (no interrupt support)
        set +e
        shopt -s lastpipe 2>/dev/null  # keep pipe while-loop in parent shell
        "${curl_args[@]}" 2>"$_err_file" | while IFS= read -r _line; do
            [[ -z "$_sse_first_line" && -n "$_line" ]] && _sse_first_line="$_line"
            _sse_parse_line "$_line"
        done || true
    fi

    [[ -n "$_body_file" ]] && rm -f "$_body_file"

    log "DEBUG: [API] http_sse_connect: done got_data=$_sse_got_data"

    if [[ "${_sse_got_data:-0}" -eq 0 ]]; then
        _SSE_FIRST_LINE="${_sse_first_line:-}"
        [[ -n "$_sse_first_line" ]] && log "SSE: no events. First line: ${_sse_first_line:0:200}"
        return 1
    fi
    _SSE_FIRST_LINE=""
    return 0
}

# ============================================================================
# SECTION 7: Agent System — load, call, task tool
# ============================================================================

# Agent storage: AGENTS[name]=prompt, AGENT_META[name]=json_metadata
declare -A AGENTS AGENT_META AGENT_STATUS AGENT_DISCOVERS
AGENT_DESCRIPTIONS=""  # precomputed during load_agents, reused per turn
BASHAGT_MAX_SUBAGENTS="${BASHAGT_MAX_SUBAGENTS:-4}"
JOBS_DIR=""

# ── Model profile routing ──
declare -A MODEL_PROFILES          # name → JSON config string
# Currently active profile state (empty = use flat BASHAGT_* globals)
_PROF_NAME="" _PROF_MODEL="" _PROF_API_URL="" _PROF_API_KEY=""
_PROF_AUTH_HEADER="" _PROF_AUTH_PREFIX="" _PROF_PROTOCOL=""
_PROF_MAX_TOKENS="" _PROF_THINKING_BUDGET=""
# Routing configuration
BASHAGT_MAIN_PROFILE=""            # main conversation profile
BASHAGT_COMPRESS_PROFILE=""        # compression profile
BASHAGT_ENGRAM_PROFILE=""          # memory engram profile

# Parse a single agent .md file (JSON frontmatter + markdown body)
parse_agent_file() {
    local file="$1" source="${2:-project}"
    local raw name meta_json body in_json
    raw=$(<"$file")
    # Extract JSON frontmatter: supports both multi-line { ... } and single-line {...}
    meta_json=""; in_json=0
    while IFS= read -r line; do
        if (( in_json == 0 )); then
            if [[ "$line" == "{" ]]; then
                in_json=1; meta_json="$line"$'\n'
            elif [[ "$line" == "{"*"}" ]]; then
                meta_json="$line"; break  # single-line JSON
            fi
        elif (( in_json == 1 )); then
            meta_json+="$line"$'\n'
            [[ "$line" == "}" ]] && break
        fi
    done <<< "$raw"
    # Pure-bash body extraction: slice off the frontmatter we already parsed.
    # Avoids 2× sed + 1× printf subprocess forks per agent file.
    local _fm_len=${#meta_json}
    body="${raw:$_fm_len}"
    while [[ "$body" == $'\n'* ]]; do body="${body#$'\n'}"; done
    name=$(jq -r '.name // empty' <<<"$meta_json" 2>/dev/null)
    [[ -z "$name" ]] && { log "Skipping agent '$file': no name in frontmatter"; return 1; }

    # System agents are immutable — project cannot override
    if [[ "$source" == "project" ]] && [[ -n "${AGENTS[$name]:-}" ]]; then
        log "Agent '$name' already loaded (system), skipping project override"
        return 1
    fi

    AGENTS[$name]="$body"
    AGENT_META[$name]="$meta_json"
}

# Load all agents: system first, then project
load_agents() {
    # Clear non-engram agents, preserve engram_* infrastructure agents
    local name
    for name in "${!AGENTS[@]}"; do
        [[ "$name" == engram_* ]] && continue
        unset "AGENTS[$name]"
        unset "AGENT_META[$name]"
    done
    local f
    for f in "$HOME/.bashagt/agents/"*.md; do
        [[ -f "$f" ]] && parse_agent_file "$f" "system"
    done
    # Warn about deprecated agents (removed in bashagt v2)
    for _dep in review explain; do
        [[ -f "$HOME/.bashagt/agents/${_dep}.md" ]] && \
            log "WARN: Deprecated agent '$_dep' found at ~/.bashagt/agents/${_dep}.md — it is no longer loaded. Consider removing it."
    done
    for f in .bashagt/agents/*.md; do
        [[ -f "$f" ]] && parse_agent_file "$f" "project" || true
    done
    # Inject built-in format agent (BSRP) — always from source, never from disk
    AGENTS[format]="$FORMAT_AGENT_BODY"
    AGENT_META[format]="$FORMAT_AGENT_META"
    # Precompute agent descriptions for system prompt (saves 8 jq/turn)
    AGENT_DESCRIPTIONS=""
    for name in "${!AGENTS[@]}"; do
        [[ "$name" == engram_* || "$name" == "format" || "$name" == "plan_extractor" ]] && continue
        local desc tools_hint
        IFS=$'\t' read -r desc tools_hint < <(
            jq -r '[.description // "", (.tools | if . and (. != []) then " [tools: "+join(", ")+"]" else "" end)] | @tsv' <<<"${AGENT_META[$name]}" 2>/dev/null
        )
        AGENT_DESCRIPTIONS+="  - $name: $desc$tools_hint"$'\n'
    done
    _agent_refresh_discovery
    # Pure-bash agent counts (avoid find/wc/tr/grep pipelines)
    local _sys_count=0 _prj_count=0 _hid_count=0 _total _n
    _total=${#AGENTS[@]}
    for _n in "${!AGENTS[@]}"; do
        [[ "$_n" == engram_* ]] && _hid_count=$((_hid_count + 1))
    done
    log "DEBUG: [AGENT] load_agents: system=$_sys_count project=$_prj_count hidden=$_hid_count total=$_total"
    return 0
}

# Call a sub-agent by name. Returns text output on stdout, 0 on success.
# Core agent API call logic — extracted so async_spin can wrap it with animation
_call_agent_core() {
    local name="$1" prompt="$2"
    export AGENT_SELF_NAME="$name"
    local meta="${AGENT_META[$name]:-}"
    local max_tokens thinking_budget model tools system_prompt
    # Extract meta fields: scalars with -r, tools array with -c (single line)
    max_tokens=$(jq -r '(.max_tokens // 512)' <<< "$meta" 2>/dev/null)
    thinking_budget=$(jq -r '(.thinking_budget // 0)' <<< "$meta" 2>/dev/null)
    model=$(jq -r '(.model // "")' <<< "$meta" 2>/dev/null)
    tools=$(jq -c '(.tools // [])' <<< "$meta" 2>/dev/null)

    # ── Profile resolution for sub-agents ──
    # Chain: agent.profile > main profile > default (flat keys)
    local _agent_profile _saved_prof="$_PROF_NAME"
    _agent_profile=$(jq -r '(.profile // "")' <<< "$meta" 2>/dev/null)
    [[ -z "$_agent_profile" || "$_agent_profile" == "null" ]] && _agent_profile="${BASHAGT_MAIN_PROFILE:-}"
    _resolve_profile "$_agent_profile" || _resolve_profile ""

    # Model priority: agent.model > agent.profile.model > main.profile.model > flat.model
    local _meta_model; _meta_model=$(jq -r '(.model // "")' <<< "$meta" 2>/dev/null)
    if [[ -n "$_meta_model" && "$_meta_model" != "null" ]]; then
        model="$_meta_model"
    elif [[ -z "$model" || "$model" == "null" ]]; then
        model=$(_prof_get_field model)
    fi

    # Cache profile connection params for the API loop
    local _sa_url _sa_proto _sa_auth_h _sa_auth_px _sa_key
    _sa_url=$(_prof_get_field api_url)
    _sa_proto=$(_prof_get_field protocol)
    _sa_auth_h=$(_prof_get_field auth_header)
    _sa_auth_px=$(_prof_get_field auth_prefix)
    _sa_key=$(_prof_get_field api_key)
    # Build peer agent list from AGENT_DISCOVERS (pre-expanded, no globs)
    local _peer_list="" _pn
    local _disc_json="${AGENT_DISCOVERS[$name]:-[]}"
    while IFS= read -r _pn; do
        [[ -z "$_pn" ]] && continue
        [[ -n "$_peer_list" ]] && _peer_list+=" "
        _peer_list+="$_pn"
    done < <(jq -r '.[]' <<< "$_disc_json" 2>/dev/null)
    system_prompt="Your agent name: $name
Other running agents: ${_peer_list:-none}
You can coordinate with other agents via send_message and check_messages tools.
Current working directory: $(pwd)
${AGENTS[$name]}"

    # ── Inject model_pool for agent_manager ──
    if [[ "$name" == "agent_manager" ]]; then
        local _pool_json=""
        [[ -f "$HOME/.bashagt/model_pool.json" ]] && \
            _pool_json=$(jq -r '.models' "$HOME/.bashagt/model_pool.json" 2>/dev/null)
        if [[ -n "$_pool_json" && "$_pool_json" != "null" && "$_pool_json" != "{}" ]]; then
            local _pool_fmt
            _pool_fmt=$(echo "$_pool_json" | jq -r '
                to_entries[] |
                "  [\(.key)]\n" +
                "    description: \(.value.description // "N/A")\n" +
                "    use_cases: \([.value.use_cases // []] | join(", "))\n" +
                "    strengths: \([.value.strengths // []] | join(", "))\n" +
                "    weaknesses: \([.value.weaknesses // []] | join(", "))\n" +
                "    context_window: \(.value.context_window // "N/A")\n" +
                "    extended_thinking: \(.value.extended_thinking // false)\n" +
                "    profiles: \([.value.profiles // []] | join(", "))"')
            system_prompt+=$'\n\n'"━━━ MODEL POOL ━━━"$'\n\n'"$_pool_fmt"
        fi
    fi

    local tools_json="[]"
    if [[ -n "$tools" && "$tools" != "null" && "$tools" != "[]" ]]; then
        local t_names tname _schema_lines=""
        t_names=$(jq -r '.[]' <<< "$tools" 2>/dev/null)
        while IFS= read -r tname; do
            case "$tname" in
                read_file)  _schema_lines+="$TOOL_READ_FILE_SCHEMA"$'\n' ;;
                write_file) _schema_lines+="$TOOL_WRITE_FILE_SCHEMA"$'\n' ;;
                edit_file)  _schema_lines+="$TOOL_EDIT_FILE_SCHEMA"$'\n' ;;
                delete_file)_schema_lines+="$TOOL_DELETE_FILE_SCHEMA"$'\n' ;;
                bash)       _schema_lines+="$TOOL_BASH_SCHEMA"$'\n' ;;
                list_files) _schema_lines+="$TOOL_LIST_FILES_SCHEMA"$'\n' ;;
                web_search)   _schema_lines+="$TOOL_WEB_SEARCH_SCHEMA"$'\n' ;;
                task_create)  _schema_lines+="$TOOL_TASK_CREATE_SCHEMA"$'\n' ;;
                task_update)  _schema_lines+="$TOOL_TASK_UPDATE_SCHEMA"$'\n' ;;
                task_list)    _schema_lines+="$TOOL_TASK_LIST_SCHEMA"$'\n' ;;
                agent)        _schema_lines+="$(build_agent_schema)"$'\n' ;;
            esac
        done <<< "$t_names"
        tools_json=$(printf '%s\n' "$_schema_lines" | jq -s '.' 2>/dev/null || echo '[]')
    fi

    local _has_disc; _has_disc=$(jq -r '(.discovers // []) | length' <<< "$meta" 2>/dev/null)
    if [[ "$_has_disc" -gt 0 ]]; then
        if [[ "$tools_json" == "[]" ]]; then
            tools_json=$(printf '%s\n' "$TOOL_SEND_MESSAGE_SCHEMA" "$TOOL_CHECK_MESSAGES_SCHEMA" | jq -s '.')
        else
            tools_json=$(printf '%s\n' "$TOOL_SEND_MESSAGE_SCHEMA" "$TOOL_CHECK_MESSAGES_SCHEMA" | jq -s --argjson base "$tools_json" '$base + .')
        fi
    fi

    local _msgs; _msgs=$(jq -nc --arg prompt "$prompt" '[{role:"user",content:$prompt}]')
    local request_body response text err
    local _sa_iter=0

    local _tool_count; _tool_count=$(jq 'length' <<< "$tools_json" 2>/dev/null || echo 0)
    log "DEBUG: [AGENT] _call_agent_core: name=$name model=$model thinking=$thinking_budget tools=$_tool_count max_iter=unlimited"

    # Inject cache_control on tools[-1] (BP2) + system[-1] (BP1) — stable
    # for same agent type re-calls. Check probe state to avoid wasting bytes.
    local _sa_sys_cc _sa_tools_cc _sa_emit=0
    _sa_sys_cc=$(jq -nc --arg txt "$system_prompt" \
        '[{type:"text", text:$txt}]')
    if _pe_cache_active; then
        _sa_emit=1
        _sa_sys_cc=$(jq --argjson CM "$CACHE_MARKER_JSON" \
            'if length > 0 then .[-1] += $CM else . end' <<< "$_sa_sys_cc")
        _sa_tools_cc=$(jq -S --argjson CM "$CACHE_MARKER_JSON" \
            'if length > 0 then .[-1] += $CM else . end' <<< "$tools_json")
    else
        _sa_tools_cc="$tools_json"
    fi

    while true; do
        _sa_iter=$((_sa_iter + 1))
        log "DEBUG: [AGENT] iter=$_sa_iter name=$name pid=$$"
        request_body=$(build_request_body "$model" "$max_tokens" \
            "$_sa_sys_cc" "$_msgs" "$_sa_tools_cc" "$thinking_budget" "false" "1")

        local _sa_out; _sa_out=$(_mktemp_file /tmp/bashagt_sa.XXXXXX)
        local _sa_av_arg=()
        [[ "$_sa_proto" != "openai" ]] && _sa_av_arg=(--header "anthropic-version: 2023-06-01")
        http_post "$_sa_url" "$_sa_out" \
            --connect-timeout "${BASHAGT_CONNECT_TIMEOUT:-10}" --max-time 900 \
            --body "$request_body" \
            --auth-header "$_sa_auth_h" --auth-value "${_sa_auth_px}${_sa_key}" \
            "${_sa_av_arg[@]}" --header "content-type: application/json"
        local _sa_rc=$?
        if [[ $_sa_rc -ne 0 ]]; then
            # HTTP error: capture message and fail immediately
            local _httperr=''
            if [[ -s "$_sa_out" ]]; then
                _httperr=$(jq -r '.error.message // ""' "$_sa_out" 2>/dev/null)
            fi
            rm -f "$_sa_out"
            _resolve_profile "$_saved_prof" || _resolve_profile ""
            log "Agent '$name' HTTP $_sa_rc: ${_httperr:-unknown}" >&2; return 1
        fi
        response=$(< "$_sa_out")
        rm -f "$_sa_out"

        # Convert OpenAI response to internal Anthropic format
        if [[ "$_sa_proto" == "openai" ]]; then
            response=$(_proto_convert_response <<< "$response")
        fi

        local _stop_reason; _stop_reason=$(jq -r '.stop_reason // "end_turn"' <<< "$response" 2>/dev/null)
        local _tool_uses
        _tool_uses=$(jq -c '[.content[] | select(.type=="tool_use")]' <<< "$response" 2>/dev/null)
        [[ -z "$_tool_uses" ]] && _tool_uses='[]'
        local _tu_count; _tu_count=$(jq 'length' <<< "$_tool_uses" 2>/dev/null || echo 0)
        log "DEBUG: [AGENT] _call_agent_core: iter=$_sa_iter stop=$_stop_reason tools=$_tu_count"

        # Defensive: if model returned end_turn but still has tool_use blocks,
        # override to tool_use — same as main agent loop (line 11819-11825).
        if [[ "$_stop_reason" == "end_turn" ]] && [[ "$_tool_uses" != "[]" ]]; then
            _stop_reason="tool_use"
        fi

        case "$_stop_reason" in
            end_turn) break ;;
            tool_use) ;;          # fall through → execute tools below
            *) break ;;           # unknown → treat as end_turn
        esac

        # Execute tools and feed results back for another round
        local _results_json="[" _count; _count=$_tu_count
        local _i; for (( _i=0; _i<_count; _i++ )); do
            local _tid _tname _tinput _toutput _to_tmp _tool_start
            _tid=$(jq -r ".[$_i].id" <<< "$_tool_uses" 2>/dev/null || echo "?")
            _tname=$(jq -r ".[$_i].name" <<< "$_tool_uses" 2>/dev/null || echo "?")
            _tinput=$(jq -c ".[$_i].input" <<< "$_tool_uses" 2>/dev/null || echo "{}")
            log "DEBUG: [AGENT] tool_call: iter=$_sa_iter name=$_tname id=$_tid input_size=${#_tinput}"
            _tool_start=$(_timestamp_ms)
            _to_tmp=$(_mktemp_file /tmp/bashagt_subtool.XXXXXX 2>/dev/null)
            if [[ -n "$_to_tmp" ]] && [[ -f "$_to_tmp" ]]; then
                dispatch_tool "$_tname" "$_tinput" > "$_to_tmp" 2>/dev/null || echo "Tool failed" > "$_to_tmp"
                _toutput=$(< "$_to_tmp")
                rm -f "$_to_tmp"
            else
                _toutput=$(dispatch_tool "$_tname" "$_tinput" 2>/dev/null || echo "Tool failed")
            fi
            local _tool_elapsed; _tool_elapsed=$(($(_timestamp_ms) - _tool_start))
            log "DEBUG: [AGENT] tool_result: iter=$_sa_iter name=$_tname output_size=${#_toutput} elapsed=${_tool_elapsed}ms"
            local _tresult; _tresult=$(format_tool_result "$_tid" "$_toutput" false)
            (( _i > 0 )) && _results_json+=","
            _results_json+="$_tresult"
        done
        _results_json+="]"
        # Use full response content (including thinking blocks) for assistant message.
        # DeepSeek requires thinking blocks to be passed back when thinking=enabled.
        local _full_content; _full_content=$(jq -c '.content' <<< "$response" 2>/dev/null)
        _msgs=$(jq --argjson content "$_full_content" --argjson results "$_results_json" \
            '. + [{role:"assistant",content:$content}, {role:"user",content:$results}]' <<< "$_msgs")
    done

    text=$(jq -r '(.content[] | select(.type=="text") | .text // "")' <<< "$response" 2>/dev/null)
    if [[ -z "$text" ]]; then
        # Fallback: DeepSeek may return only thinking blocks
        text=$(jq -r '(.content[] | select(.type=="thinking") | .thinking // "")' <<< "$response" 2>/dev/null)
    fi
    err=$(jq -r '.error.message // ""' <<< "$response" 2>/dev/null)
    if [[ -z "$text" ]]; then
        _resolve_profile "$_saved_prof" || _resolve_profile ""
        log "Agent '$name' failed: ${err:-unknown}" >&2; return 1
    fi
    log "DEBUG: [AGENT] _call_agent_core: done name=$name iters=$_sa_iter text_len=${#text}"
    _resolve_profile "$_saved_prof" || _resolve_profile ""
    _log_flush  # flush child buffer before process exit
    printf '%s' "$text"
}

call_agent() {
    local name="$1" prompt="$2" desc="${3:-}"
    if [[ -z "${AGENTS[$name]:-}" ]]; then
        log "Agent '$name' not found"
        return 1
    fi

    local _agent_model; _agent_model=$(jq -r '(.model // "")' <<< "${AGENT_META[$name]:-}" 2>/dev/null)
    local _agent_think; _agent_think=$(jq -r '(.thinking_budget // 0)' <<< "${AGENT_META[$name]:-}" 2>/dev/null)
    local _agent_tools; _agent_tools=$(jq -c '(.tools // [])' <<< "${AGENT_META[$name]:-}" 2>/dev/null)
    local _agent_tool_count; _agent_tool_count=$(jq 'length' <<< "$_agent_tools" 2>/dev/null || echo 0)
    log "DEBUG: [AGENT] call_agent: name=$name model=${_agent_model:-$BASHAGT_MODEL} thinking=$_agent_think tools=$_agent_tool_count"

    AGENT_STATUS[$name]="busy"
    local _agent_out; _agent_out=$(_mktemp_file /tmp/bashagt_agent.XXXXXX)
    if [[ -z "$_agent_out" ]] || [[ ! -f "$_agent_out" ]]; then
        _call_agent_core "$name" "$prompt"
        local _rc=$?
        AGENT_STATUS[$name]="idle"
        return $_rc
    fi

    local _prompt_desc
    if [[ -n "$desc" && "$desc" != "null" ]]; then
        _prompt_desc="$desc"
    else
        _prompt_desc="${prompt//$'\n'/ }"
    fi
    (( ${#_prompt_desc} > 80 )) && _prompt_desc="${_prompt_desc:0:77}..."
    local _display_label="${_AGENT_DISPLAY_LABEL:-$name}"
    _log_flush  # flush parent buffer before fork (child gets clean copy)
    async_spin --dot --desc "$_prompt_desc" "$_display_label" "done" "$_agent_out" _call_agent_core "$name" "$prompt"
    local rc=$?
    AGENT_STATUS[$name]="${AGENT_STATUS[$name]:-idle}"
    [[ $rc -ne 0 ]] && AGENT_STATUS[$name]="error"
    [[ $rc -eq 0 && "${AGENT_STATUS[$name]}" == "busy" ]] && AGENT_STATUS[$name]="idle"
    if [[ $rc -eq 0 ]]; then
        printf '%s' "$_async_out"
    fi
    log "DEBUG: [AGENT] call_agent: done name=$name rc=$rc text_len=${#_async_out}"
    rm -f "$_agent_out"
    return $rc
}

# Dynamic task tool schema — reflects current AGENTS
build_agent_schema() {
    local alist=""
    for name in "${!AGENTS[@]}"; do
        [[ "$name" == engram_* || "$name" == "format" || "$name" == "plan_extractor" ]] && continue
        local desc; desc=$(echo "${AGENT_META[$name]}" | jq -r '.description // ""' 2>/dev/null)
        alist+="$name ($desc), "
    done
    alist="${alist%, }"
    jq -n --arg alist "$alist" '{
        name:"agent",
        description:("Delegate multi-step work to a sub-agent. Available: \($alist). Use agent for: planning before coding, code review after coding, complex searches, or summarization. Sub-agents can use tools independently across multiple rounds."),
        input_schema:{type:"object", properties:{
            agent:{type:"string", description:"Sub-agent name"},
            description:{type:"string", description:"One-line task summary (≤80 chars), shown in the UI spinner. Be specific about target, scope, and expected output. Example: \"Find all callers of handle_auth in src/\" not \"Explore the code\"."},
            prompt:{type:"string", description:"Detailed task brief for the sub-agent (≤2000 chars). Three parts: (1) Provide necessary compressed context — paste relevant code snippets, file paths, errors, findings, etc. Give only the minimum context needed, avoiding information overload and wasted exploration turns. (2) Instructions — what to do, what to find, what output format. (3) Completion criteria — when the task is considered done."},
            async:{type:"boolean", description:"If true, return a job_id immediately instead of waiting. Use job_poll/job_result to manage the async job."}
        }, required:["agent","prompt","description"]}
    }'
}

# task tool implementation
# ── Plan sub-agent wrapper: run agent → save plan.md → request confirmation ──
_tool_agent_plan() {
    local prompt="$1" async="${2:-false}" desc="${3:-}" _target="${BASHAGT_PROJECT_DIR:-$PWD}"

    # 0. Existing plan detection
    local _state; _state=$(_plan_state "$_target" 2>/dev/null) || _state="idle"
    if [[ "$_state" == "active" ]]; then
        # In interactive mode, ask user. In stream mode, proceed with new plan.
        if (exec 2>/dev/tty) 2>/dev/null && [[ "${BASHAGT_DAEMON_WORKER:-0}" != "1" ]] && [[ "${BASHAGT_OE_RAW:-0}" != "1" ]]; then
            local _exist_result
            _exist_result=$(_request_ui "A plan already exists (.bashagt/plan.md)" \
                '["Continue existing plan","Create new plan","Cancel"]' \
                "Existing plan has active TODOs. Choose an action." \
                2>/dev/tty)
            local _exist_choice
            _exist_choice=$(jq -r '.choice_index // -1' <<< "$_exist_result" 2>/dev/null)
            case "$_exist_choice" in
                0)  # Continue — just return plan file reference
                    printf '{"result":"existing_plan","plan_file":".bashagt/plan.md","message":"Continuing with existing plan. Read plan.md for reference."}'
                    return 0
                    ;;
                2)  # Cancel
                    printf '{"result":"cancelled","message":"Plan creation cancelled."}'
                    return 0
                    ;;
            esac
            # 1 = Create new plan — fall through
        fi
    fi

    # 1. Run plan sub-agent
    # Redirect stdout→/dev/null: call_agent prints sub-agent output to stdout
    # (line 4291), but we capture it via _async_out. Letting it through would
    # leak the full plan text into the tool output file, causing double display.
    call_agent "plan" "$prompt" "$desc" > /dev/null
    local _rc=$?
    local _plan_output="${_async_out:-}"
    _agent_sched_tick

    # 2. If sub-agent failed, return error
    if (( _rc != 0 )) || [[ -z "$_plan_output" ]]; then
        printf '{"result":"error","message":"Plan sub-agent failed to produce output."}'
        return $_rc
    fi

    # 3. Save plan to file
    _plan_save "$_plan_output" "$_target"

    # 4. Output neutral status — no instructions here (deferred message is
    # the single source of truth for what the model should do next).
    # Including "read_file" etc. in the tool result contradicts the deferred
    # reject message, causing the model to summarize the plan after rejection.
    printf '{"result":"plan_pending"}'

    # 5. Display formatted plan — stream directly to terminal, no spinner
    local _display_fd _display_prefix="  "
    if [[ -e /proc/self/fd/8 || -e /dev/fd/8 ]]; then
        _display_fd=8
    elif [[ -w /dev/tty ]]; then
        _display_fd="/dev/tty"
    else
        _display_fd=1
    fi

    # Header
    printf '\n  %s━━━ Plan: .bashagt/plan.md ━━━%s\n' "$DIM" "$RESET" >&"$_display_fd"

    if printf '%s' "$_plan_output" | grep -qE '<(b|dim|g|c|y|r|gray|lg|ly|inv)>'; then
        # Fast path: plan agent already included format tags
        while IFS= read -r _pline; do
            printf '  %s\n' "$(_fmt_postprocess <<< "$_pline")" >&"$_display_fd"
        done <<< "$_plan_output"
    else
        format_text_stream "$_plan_output" "${#_plan_output}" \
            "$TERM_WIDTH" "$_display_fd" "$_display_prefix" || {
            while IFS= read -r _pline; do
                printf '  %s\n' "$_pline" >&"$_display_fd"
            done <<< "$_plan_output"
        }
    fi

    # Footer
    printf '  %s━━━━━━━━━━━━━━━━━━━━━━━━━%s\n\n' "$DIM" "$RESET" >&"$_display_fd"

    # 6. Trigger confirmation
    # Route: foreground _request_ui (REPL/pipe with internal renderer) vs
    #        async request_pending frame (daemon or hotkey raw JSONL) vs
    #        auto-approve (no tty, e.g. script)
    #
    # Key insight: _stream_run_turn / _stream_run_oneshot use a BACKGROUND
    # renderer subshell that cannot access /dev/tty (SIGTTIN).  So we must
    # call _request_ui directly from this foreground process.  Only the raw
    # JSONL path (BASHAGT_OE_RAW=1, hotkey --stream) can use _request_async
    # because the hotkey's foreground shell runs _stream_render itself.
    if (exec 2>/dev/tty) 2>/dev/null && [[ "${BASHAGT_DAEMON_WORKER:-0}" != "1" ]] && [[ "${BASHAGT_OE_RAW:-0}" != "1" ]]; then
        # Interactive (REPL/pipe): foreground _request_ui, tty accessible
        local _result _ctx
        _ctx="Plan: .bashagt/plan.md"
        _result=$(_request_ui "Approve this plan?" \
            '["Approve → create TODOs","Reject"]' \
            "$_ctx" \
            2>/dev/tty)
        _plan_handle_response "$_result" "$_target"
    elif [[ "${BASHAGT_DAEMON_WORKER:-0}" == "1" ]]; then
        # Daemon worker: async request_pending frame (hotkey handles UI)
        _request_async "Approve this plan?" \
            '["Approve → create TODOs","Reject"]' \
            "Plan: .bashagt/plan.md" \
            "plan_confirm"
        _PLAN_PENDING=1
    elif [[ "${BASHAGT_OE_RAW:-0}" == "1" ]]; then
        # Raw JSONL (hotkey --stream): foreground shell runs _stream_render,
        # can access /dev/tty → request_pending frame works correctly
        _request_async "Approve this plan?" \
            '["Approve → create TODOs","Reject"]' \
            "Plan: .bashagt/plan.md" \
            "plan_confirm"
        _PLAN_PENDING=1
    else
        # No tty available (script/pipe): auto-approve, create TODOs directly
        _stream_emit "info" "$(jq -nc --arg c 'Plan auto-approved (non-interactive).' '{content: $c}')"
        _plan_handle_response '{"result":"selected","choice_index":0}' "$_target"
    fi
}

tool_agent() {
    local agent="$1" prompt="$2" async="${3:-false}" desc="${4:-}"
    if [[ -z "$agent" ]]; then
        printf 'ERROR: agent name is required (empty string received).\n'
        return 1
    fi
    if [[ -z "${AGENTS[$agent]:-}" ]]; then
        printf 'Error: agent "%s" not found. Available: %s\n' "$agent" "${!AGENTS[*]}"
        return 1
    fi
    # Plan agent gets the unified confirmation flow
    if [[ "$agent" == "plan" ]]; then
        _tool_agent_plan "$prompt" "$async" "$desc"
        return $?
    fi
    if [[ "$async" == "true" ]]; then
        agent_submit "$agent" "$prompt"
        return $?
    fi
    call_agent "$agent" "$prompt" "$desc"
    local _rc=$?
    # After memory-related agents, dispatch any pending engram inboxes
    case "$agent" in
        mem_writer|mem_searcher)
            _mem_dispatch_all
            ;;
    esac
    _agent_sched_tick
    return $_rc
}

# Send a message to another agent's inbox. Broadcast delivers to all agents.
tool_send_message() {
    local to="$1" content="$2"
    if [[ -z "$to" ]] || [[ -z "$content" ]]; then
        printf 'Error: both "to" and "content" are required\n'
        return 1
    fi
    local _self="${AGENT_SELF_NAME:-unknown}"
    log "DEBUG: [AGENT] send_message: from=$_self to=$to size=${#content}B"
    local _disc_json="${AGENT_DISCOVERS[$_self]:-[]}"
    local msg; msg=$(jq -n --arg from "$_self" --arg to "$to" \
        --argjson ts "${EPOCHSECONDS:-$(date +%s)}" --arg content "$content" \
        '{from:$from, to:$to, ts:$ts, content:$content}')
    if [[ "$to" == "broadcast" ]]; then
        local _name _count=0
        while IFS= read -r _name; do
            [[ -z "$_name" ]] && continue
            local _inbox="$COMM_DIR/${_name}.jsonl"
            mkdir -p "$COMM_DIR"
            _lock_acquire "${_inbox}"
                printf '%s\n' "$msg" >> "$_inbox"
                _lock_release "${_inbox}"
            _count=$((_count + 1))
        done < <(jq -r '.[]' <<< "$_disc_json" 2>/dev/null)
        printf 'Broadcast sent to %d agents (%d bytes)\n' "$_count" "${#content}"
    else
        # Validate target is in discovers list
        if ! jq -e --arg t "$to" 'index($t)' <<< "$_disc_json" > /dev/null 2>&1; then
            printf 'Error: "%s" is not in your discovers list. Available: %s\n' \
                "$to" "$(jq -r '.[]' <<< "$_disc_json" 2>/dev/null | tr '\n' ' ')"
            return 1
        fi
        local inbox="$COMM_DIR/${to}.jsonl"
        mkdir -p "$COMM_DIR"
        _lock_acquire "${inbox}"
            printf '%s\n' "$msg" >> "$inbox"
            _lock_release "${inbox}"
        printf 'Message sent to %s (%d bytes)\n' "$to" "${#content}"
    fi
}

# Read and clear this agent's inbox. Returns JSONL on stdout.
tool_check_messages() {
    local inbox="$COMM_DIR/${AGENT_SELF_NAME:-unknown}.jsonl"
    if [[ -f "$inbox" ]] && [[ -s "$inbox" ]]; then
        _lock_acquire "${inbox}"
            cat "$inbox"
            :> "$inbox"
            _lock_release "${inbox}"
    else
        printf '[]\n'
    fi
}

# ── Agent batch invocation ──
tool_agent_batch() {
    local input_json="$1"
    local task_count
    task_count=$(jq '.tasks | length' <<< "$input_json")

    [[ $task_count -eq 0 ]] && { printf 'agent_batch: tasks array is empty\n'; return 1; }

    # ---- Phase 1: validate + submit all tasks ----
    local _BATCH_IDS=() _BATCH_AGENTS=() _BATCH_DESCS=()
    local _stm_fd=${_BYPASS_FD:-1}
    local i agent_name prompt jid _desc
    for (( i=0; i<task_count; i++ )); do
        agent_name=$(jq -r ".tasks[$i].agent" <<< "$input_json")
        prompt=$(jq -r ".tasks[$i].prompt" <<< "$input_json")

        if [[ -z "${AGENTS[$agent_name]:-}" ]]; then
            printf 'agent_batch: agent "%s" not found. Available: %s\n' \
                "$agent_name" "${!AGENTS[*]}"
            return 1
        fi

        # Task desc: model description preferred, fallback to prompt
        _desc=$(jq -r ".tasks[$i].description // \"\"" <<< "$input_json")
        if [[ -z "$_desc" || "$_desc" == "null" ]]; then
            _desc="${prompt//$'\n'/ }"
        fi
        (( ${#_desc} > 80 )) && _desc="${_desc:0:77}..."

        jid=$(agent_submit "$agent_name" "$prompt")
        _BATCH_IDS+=("$jid")
        _BATCH_AGENTS+=("$agent_name")
        _BATCH_DESCS+=("$_desc")
    done

    # ---- Phase 2: emit tree + spinner ----
    local _total=${#_BATCH_IDS[@]}
    local _idx _branch
    for (( _idx=0; _idx<_total; _idx++ )); do
        if (( _idx == _total - 1 )); then
            _branch='└'
        else
            _branch='├'
        fi
        _stream_emit "info" "$(jq -nc \
            --arg c "  ${_branch} ${_BATCH_AGENTS[$_idx]} · ${_BATCH_DESCS[$_idx]}" \
            '{content: $c}')" >&$_stm_fd
    done

    local _start_ts=$(_timestamp_ms) _tick_count=0

    _dot_tick
    _stream_emit "status_begin" "$(jq -nc \
        --arg icon "$_DOT_FRAME" --arg label "agent_batch" \
        --arg ela "0s" --arg desc "${_total} tasks, 0/${_total} done, ${_total} running" \
        '{icon: $icon, label: $label, elapsed_str: $ela, desc: $desc}')" >&$_stm_fd

    # ---- Phase 3: poll loop with status updates ----
    local _pending=true _done_count=0 _failed_count=0 _running st
    while [[ $_pending == true ]]; do
        [[ "${_bagt_interrupted:-0}" == "1" ]] && { _pending=false; break; }
        _pending=false
        _done_count=0
        _failed_count=0
        for jid in "${_BATCH_IDS[@]}"; do
            st=$(agent_poll "$jid")
            case "$st" in
                running|queued) _pending=true ;;
                done)          _done_count=$((_done_count + 1)) ;;
                failed|cancelled) _failed_count=$((_failed_count + 1)) ;;
            esac
        done

        _tick_count=$((_tick_count + 1))
        # Reap crashed workers every 50 polls (~5s) so a worker that dies without
        # updating its job JSON (OOM, segfault) is detected and doesn't hang forever.
        if (( _tick_count % 50 == 0 )); then
            _agent_sched_reap
        fi
        if (( _tick_count % 5 == 0 )); then
            _dot_tick
            local _elapsed; _elapsed=$(( $(_timestamp_ms) - _start_ts ))
            _running=$((_total - _done_count - _failed_count))
            if (( _failed_count > 0 )); then
                _desc="${_done_count}/${_total} done, ${_failed_count}/${_total} failed, ${_running} running"
            else
                _desc="${_done_count}/${_total} done, ${_running} running"
            fi
            _stream_emit "status_update" "$(jq -nc \
                --arg icon "$_DOT_FRAME" --arg label "agent_batch" \
                --arg ela "$(ui_time $_elapsed)" --arg desc "$_desc" \
                '{icon: $icon, label: $label, elapsed_str: $ela, desc: $desc}')" >&$_stm_fd
        fi

        [[ $_pending == true ]] && { _spin_sleep 0.1 || _pending=false; }
    done

    # ---- Cancel running jobs on interrupt (batched fast-kill) ----
    if [[ "${_bagt_interrupted:-0}" == "1" ]]; then
        local _jf _st _pid _ag
        # Pass 1: TERM all running jobs (instant)
        for jid in "${_BATCH_IDS[@]}"; do
            _jf="$JOBS_DIR/${jid}.json"
            [[ -f "$_jf" ]] || continue
            _st=$(jq -r '.status // ""' "$_jf" 2>/dev/null)
            [[ "$_st" == "running" ]] || continue
            _pid=$(jq -r '.pid // 0' "$_jf" 2>/dev/null)
            [[ "$_pid" -gt 0 ]] && _proc_kill "$_pid" TERM
        done
        sleep 0.5
        # Pass 2: KILL survivors + mark cancelled + reset agent idle
        for jid in "${_BATCH_IDS[@]}"; do
            _jf="$JOBS_DIR/${jid}.json"
            [[ -f "$_jf" ]] || continue
            _st=$(jq -r '.status // ""' "$_jf" 2>/dev/null)
            if [[ "$_st" == "running" ]]; then
                _pid=$(jq -r '.pid // 0' "$_jf" 2>/dev/null)
                [[ "$_pid" -gt 0 ]] && _proc_kill "$_pid" KILL
                _job_update_status "$jid" "cancelled" '.finished_at = (now | floor)'
                _ag=$(jq -r '.agent // ""' "$_jf" 2>/dev/null)
                [[ -n "$_ag" ]] && AGENT_STATUS[$_ag]="idle"
            elif [[ "$_st" == "queued" ]]; then
                _job_update_status "$jid" "cancelled" '.finished_at = (now | floor)'
            fi
        done
    fi
    # NOTE: do NOT clear _bagt_interrupted — let run_turn see it
    # ---- Phase 4: status_done ----
    local _final_elapsed; _final_elapsed=$(( $(_timestamp_ms) - _start_ts ))
    _stream_emit "status_done" "$(jq -nc \
        --arg label "agent_batch-done" --arg ela "$(ui_time $_final_elapsed)" \
        --arg desc "${_total} tasks" \
        '{label: $label, elapsed_str: $ela, desc: $desc}')" >&$_stm_fd


    # ---- Phase 5: collect results (plain output, no banners) ----
    for (( _idx=0; _idx<_total; _idx++ )); do
        agent_result "${_BATCH_IDS[$_idx]}" 2>/dev/null
    done
    return 0
}

# ── Skill invocation ──
tool_skill() {
    local name="$1" task="$2"
    [[ -z "$name" ]] && { printf 'Error: skill name required. Use list_skills to see available skills.\n'; return 1; }
    [[ -n "${SKILLS[$name]:-}" ]] || {
        printf 'Unknown skill: %s\n\nAvailable skills:\n' "$name"
        local _sn _sd
        for _sn in "${!SKILL_META[@]}"; do
            _sd=$(echo "${SKILL_META[$_sn]}" | jq -r '.description // ""' 2>/dev/null)
            printf '  %s  %s\n' "$_sn" "$_sd"
        done
        return 1
    }
    local body="${SKILLS[$name]}" desc
    desc=$(echo "${SKILL_META[$name]}" | jq -r '.description // ""' 2>/dev/null)
    printf 'Skill: %s\n' "$name"
    [[ -n "$desc" ]] && printf 'Description: %s\n\n' "$desc"
    printf -- '%s\n\n' "$body"
    printf -- '%s\n' '---'
    printf 'Execute the skill using native tool calls, not text markup.\n'
    printf 'To ask the user: call request("question", ["option",...])\n'
    printf 'Skill task: %s\n' "$task"
    return 0
}

# ── Resource discovery tools ──
tool_list_skills() {
    load_skills
    printf 'AVAILABLE SKILLS\n\n'
    local _sn _sd _tag
    for _sn in "${!SKILL_META[@]}"; do
        _sd=$(echo "${SKILL_META[$_sn]}" | jq -r '.description // ""' 2>/dev/null)
        _tag=""
        [[ " ${ACTIVE_SKILLS[*]} " =~ " ${_sn} " ]] || _tag=" [off]"
        printf '  %-24s %s%s\n' "$_sn" "$_sd" "$_tag"
    done
    if [[ ${#SKILL_META[@]} -eq 0 ]]; then
        printf '  (no skills installed)\n'
    fi
    printf '\nUse skill("name", "task") to invoke one.\n'
}

tool_list_agents() {
    printf 'AVAILABLE SUB-AGENTS\n\n'
    local _an
    for _an in "${!AGENTS[@]}"; do
        [[ "$_an" == engram_* || "$_an" == "format" || "$_an" == "plan_extractor" ]] && continue
        local _desc _tools
        _desc=$(echo "${AGENTS[$_an]}" | jq -r '.description // ""' 2>/dev/null)
        _tools=$(echo "${AGENTS[$_an]}" | jq -r '.tools | join(", ")' 2>/dev/null)
        printf '  %-24s %s\n' "$_an" "$_desc"
        [[ -n "$_tools" ]] && printf '    Tools: %s\n' "$_tools"
    done
    printf '\nUse agent("name", "prompt") to delegate work to one.\n'
}

tool_list_mcp_tools() {
    printf 'MCP TOOLS BY SERVER\n\n'
    local _name _tools _count _i _tool _tname _tdesc
    local _found=0
    for _name in "${!MCP_SERVER_TOOLS[@]}"; do
        [[ "${MCP_SERVER_READY[$_name]:-0}" != "1" ]] && continue
        _found=1
        _tools="${MCP_SERVER_TOOLS[$_name]}"
        _count=$(jq 'length' <<< "$_tools" 2>/dev/null || echo 0)
        printf '  Server: %s (%d tools)\n' "$_name" "$_count"
        for ((_i=0; _i<_count; _i++)); do
            _tool=$(jq -c ".[$_i]" <<< "$_tools" 2>/dev/null)
            _tname=$(jq -r '.name' <<< "$_tool" 2>/dev/null)
            _tdesc=$(jq -r '.description // ""' <<< "$_tool" 2>/dev/null)
            printf '    %s  %s\n' "$_tname" "$_tdesc"
        done
    done
    if [[ $_found -eq 0 ]]; then
        printf '  (no MCP servers connected)\n'
    fi
    printf '\nInvoke via mcp__<server>__<tool>(...).\n'
}

# ── Built-in BSRP format agent (injected from source, not disk) ──
read -r -d '' FORMAT_AGENT_BODY << 'AGEF' || true  # -d '' → rc=1 on EOF (no NUL found)
NEW. Your ONLY job: transform raw agent output into terminal-optimized
text using the rules below. Use emoji prefixes (§EMOJI) for scanability,
semantic tags (§COLOR) for highlighting, and structure rules (§S) for layout.
Output formatted text directly — no preamble, no explanation, no markdown fences.

━━━ CORE PRINCIPLE ━━━

BSRP/2.0 — Bashagt Standard Rendering Protocol. Conformant output MUST NOT
use markdown (S6), MUST close all tags (T1), and SHOULD apply semantic
detection (D1-D12) before rendering. Rules use RFC 2119 weighting:
MUST (unconditional), SHOULD (preferred), MAY (optional).

The input is raw content from an LLM agent. Output MUST be:
  - Readable in a PLAIN TEXT terminal (no markdown rendering)
  - Dense but scannable — every line carries information
  - Self-contained — no "Here is the formatted output:" meta-text

━━━ STRUCTURE RULES ━━━

S1. One logical item per line. Use 2-space indent for nesting/hierarchy.
S2. Code blocks: wrap in <code>...</code> tags. Inside, embed raw ANSI color
    codes around tokens (see §CODE). Preserve code verbatim.
S3. Section breaks: single blank line between distinct sections.
S4. File paths as headers: "<path>path/to/file</path>" on its own line.
    Prefix with 📄 (file) or 📁 (directory) for scanability.
S5. Remove ALL meta-commentary (acknowledgments, process narration,
    meta-descriptions, self-praise, sign-offs). Keep only factual content.
S6. NEVER use markdown syntax: ```, **, *, ##, `, ____, ---, >, _, | as
    column sep, 1. (numbered list).
S7. Preserve ALL literal values exactly: paths, numbers, commands, code, URLs.
S8. If input is empty, output a single <dim> line.

━━━ LINEBREAK RULES ━━━

LR1. Every distinct item on its own line. A "logical item" is ONE of: file
     path, function name, key=value pair, single-sentence fact, command,
     error message, step in sequence. Never concatenate items with commas.
LR2. Key-value pairs: one per line.
LR3. Bullet lists: each bullet on its own line.
LR4. Error lists: each error on its own line.
LR5. Change summaries: each change (+ add, - remove, ~ modify) on its own line.

━━━ SEMANTIC DETECTION ━━━

D1. CODE BLOCKS: indented text, any language, shell sessions, config files.
    Wrap in <code>...</code>. Inside: embed ANSI color codes per §CODE.
    Preserve verbatim. For shell sessions with prompts ($ or #), keep them.

D2. FILE LISTINGS: table if ≥3 cols or ≥5 rows. Paths→<path>, sizes→<ok>,
    descriptions→plain/<dim>. Use 📄 prefix for files, 📁 for directories
    (see §EMOJI).

D3. COMMAND SUGGESTIONS: command name→<cmd>, args plain, paths→<path>.
    Example: <cmd>python3</cmd> <path>hello.py</path> --verbose

D4. ERRORS/DIAGNOSTICS: location→<b>, message→<err>, line numbers→<meta>.
    Use ✘ prefix on the error header line (see §EMOJI).

D4b. MIXED RESULTS: errors→<err>, warnings→<warn>, successes→<ok>.
    Example: <err>3 errors</err>, <warn>5 warnings</warn>, <ok>42 passed</ok>

D5. SUCCESS/STATUS: success→<ok> + ✔ prefix, counts→<ok>. See §EMOJI.

D6. WARNINGS/CAVEATS: warning→<warn> + ⚠ prefix. See §EMOJI.

D7. CHANGE SUMMARIES: additions→<ok>+</ok>, deletions→<err>-</err>,
    modifications→<warn>~</warn>. Table if >3 items.

D8. TABULAR DATA: ≥3 items with ≥2 attributes each. Box-drawing table.
    Characters: ┌ ─ ┬ ┐ ├ ┼ ┤ └ ┴ ┘ │. Right-align numbers, left-align text.
    Color VALUES not borders. Headers in <b>.

D9. EXPLANATORY TEXT: key terms→<b>, files→<path>, tools→<cmd>.
    Short paragraphs (≤120 chars). Break long ones, indent continuation +2.
    Use 💡 for tips/suggestions, ⭐ for key takeaways (see §EMOJI).

D10. STEP-BY-STEP: <b>N.</b> Description. Commands→<cmd>, paths→<path>.

D11. SEARCH RESULTS: path→<path>, line number→<meta>, match verbatim.
     Use 🔍 prefix on the result block header (see §EMOJI).

D12. CONFIG DATA: keys→<var>, values→<ok>/<warn>/<err> based on meaning.
     Use 🔧 prefix on config block headers (see §EMOJI).

━━━ CODE HIGHLIGHTING (§CODE) ━━━

CODE BLOCKS are wrapped in <code>...</code>. Inside, embed raw ANSI escape
sequences directly around each token. The color table below lists variable
names (kw, str, cmt, fn, cls, var, num, dec, esc, rst) for YOUR reference —
these are NOT XML tags. NEVER output <kw>, <cmt>, <fn> or any similar tags.
Only use the ANSI escape sequences (e.g. \033[38;2;86;156;214m).

__COLOR_SECTION__

LANGUAGE-SPECIFIC CLASSIFICATIONS:

  Shell/Bash:
    keyword: if then else elif fi for while until do done case esac in
             function local export declare typeset readonly unset shift
    builtin→fn: echo printf cd wait kill test alias unalias type command
             builtin return exit source exec trap set break continue eval
    command→fn:  ls cat grep sed awk sort curl git ssh sleep tar find ...
    variable→var: $VAR ${VAR} $@ $# $$ $? $! $0-$9 $-

  Python:
    keyword: def class return if elif else for while try except finally
             with as import from pass raise yield async await lambda global
    builtin→fn: print len range type int str list dict set open input super
             enumerate zip map filter sorted any all min max sum abs
    class→cls:   CapWords identifiers, Exception subclasses
    decorator→dec: @staticmethod @classmethod @property @dataclass ...

  JavaScript/TypeScript:
    keyword: let const var function async await class extends import export
             if else for while switch case return new this typeof instanceof
    builtin→fn: console Math JSON Promise Array Object String Number Map Set
    class→cls:   PascalCase identifiers
    decorator→dec: @Component @Injectable ...

  Generic fallback:
    Always: comments, strings, recognizable keywords
    Best-effort: functions (identifier before '('), classes (CapWords)
    SQL SELECT/FROM/WHERE→keyword  YAML keys→variable  Dockerfile FROM→keyword
    When in doubt, leave plain.

COLORING RULES:
  H1. Apply per TOKEN. Each gets its own color pair.
  H2. Priority: cmt > str > kw > cls > fn-builtin > fn-call > dec > var > num.
  H3. Comments: ENTIRE comment including # or // prefix. No inner color.
  H4. Strings: ENTIRE string including quotes. Inner escape sequences→esc.
  H5. Density: ~30-50% tokens. Keywords and strings MUST always be colored.
  H6. When uncertain, leave plain. Under-highlighted > mis-highlighted.
  H7. Merge consecutive same-color tokens to reduce output size.

━━━ COLOR TAG REFERENCE ━━━

Semantic tags for text OUTSIDE <code> blocks. Tags are converted to ANSI;
tags inside <code> blocks are ignored. Each opening tag needs a matching
closing tag (e.g. <ok>...</ok>).

  <b>text</>      Bold/emphasis      — section headers, step labels
  <dim>text</>    Dim/subdued        — secondary info, timestamps, metadata
  <ok>text</>     Success/addition   — ✓ passed, + add, completion
  <err>text</>    Error/deletion     — ✗ failed, - del, denial
  <warn>text</>   Warning/caution    — ⚠ deprecated,注意
  <path>text</>   File path / URL    — src/main.py, https://...
  <cmd>text</>    Command / tool     — python3, pip install, kill
  <meta>text</>   Metadata           — line 142, -rw-r--r--
  <sel>text</>    Selected / active  — current row, highlighted item
  <var>text</>    Variable           — $HOME, name, config_key

TAG RULES:
  T1. Every <tag> MUST have matching </tag>. No unclosed tags.
  T2. Structural tags (<b>) MAY wrap highlight tags. Highlight tags MUST NOT
      nest inside other highlight tags.
  T3. Use tags SPARINGLY. Color loses impact when everything is colored.
  T4. CODE: use <code>...</code>. Inside: raw ANSI escapes, NOT XML tags.
  T5. Tables: color VALUES, not borders or headers.
  T6. Literal angle-brackets in discussion: escape as \<b>.
  T7. Verify tag balance before outputting.

━━━ EMOJI REFERENCE ━━━

EM1. Emoji are line-start PREFIXES that classify content before reading.
     They work WITH color tags, not instead of them. Tags color the text;
     emoji identify the category. Both together = scannable terminal output.

EM2. CATEGORIES (use RFC 2119: MUST, SHOULD, MAY):

  Status (always pair with color tag):
    ✔   pass    Success, test passed, build ok, operation completed
    ✘   fail    Error, test failed, build broken, operation denied
    ⚠   warn    Warning, deprecation, requires attention
    ℹ   info    Informational note, metadata, timestamp, FYI

  Content (before <path>/<cmd>/<meta>):
    📄  file    File path header (line with <path>)
    📁  dir     Directory path header (line with <path>)
    🔍  find    Search result, grep match, found item
    📊  data    Table header, statistics, benchmark result
    🔗  link    URL, hyperlink, symlink, cross-reference

  Intent (before <b> or standalone):
    💡  tip     Suggestion, best practice, recommendation
    ⭐  key     Important, key takeaway, must-read, critical
    🐛  bug     Bug report, defect, known issue
    🔧  conf    Configuration key, environment variable, setting
    📝  note    Annotation, edit note, change description
    🚀  ship    Deploy, release, launch, publish
    ⏳  wait    Pending, in progress, waiting for result

EM3. USAGE RULES:

  ER1. Line-start ONLY. Emoji goes AFTER structural indent, BEFORE content
       tag or text. "  📄 <path>src/main.py</path>" — correct.
       "<path>📄 src/main.py</path>" — WRONG (emoji inside tag).

  ER2. One emoji per line MAX. No stacking (✔⚠), no inline emoji mid-line.

  ER3. OPTIONAL (MAY). When uncertain which emoji fits, OMIT it.
       Under-prefixed is better than wrong-prefixed.

  ER4. Inside <code> blocks: NEVER add emoji. Code output is verbatim.
       A code block MAY have a ◈ header line above it (outside <code>).

  ER5. Status emoji (✔✘⚠ℹ) SHOULD pair with matching color tags:
       ✔ + <ok>, ✘ + <err>, ⚠ + <warn>, ℹ + <dim>.
       Example: ✔ <ok>Build passed — 42 tests</ok>

  ER6. Content emoji (📄📁🔍📊🔗) go BEFORE their content tag, separated
       by a space. Example: 📄 <path>src/main.py</path>

  ER7. Intent emoji (💡⭐🐛🔧📝🚀⏳) go before <b> section header or
       standalone. Example: 💡 <b>Tip:</b> Use a virtualenv.

  ER8. Tables: emoji MAY appear in the first column as a category indicator.
       Do NOT use emoji in column headers or every cell.

  ER9. WIDTH accounting: symbols (✔✘⚠ℹ) ≈ 1 column. Emoji pictographs
       (📄📁🔍💡⭐🐛🔧📝🚀⏳📊🔗) ≈ 2 columns. Include this in E1
       line-break calculations: a line starting with a 2-col emoji has
       ~2 fewer chars available before hitting the width limit.

  ER10. No emoji on lines that ARE literal data: error messages, code
         snippets, URLs, raw values. Only prefix lines that INTRODUCE
         or CLASSIFY content, not the content itself.

━━━ TABLE RULES ━━━

TBL1. Box-drawing: ┌ ─ ┬ ┐ ├ ┼ ┤ └ ┴ ┘ │
TBL2. Right-align numbers, left-align text.
TBL3. Headers in <b>.
TBL4. Min column 4, max 45. Truncate middle for paths, end for other.
TBL5. No tables for <4 items with only 1 data column.
TBL6. Total width ≤ terminal width.
TBL7. If >100 chars, use vertical layout instead.

━━━ EXAMPLES ━━━

Input: "I created src/utils.py and tests/test_utils.py. Build passed 42 tests.
       Run with: python3 -m pytest tests/"
Output:
  📄 <path>src/utils.py</path>       <dim>helper functions</dim>
  📄 <path>tests/test_utils.py</path> <dim>unit tests</dim>

  ✔ <ok>Build passed — 42 tests</ok>

  Run: <cmd>python3</cmd> <cmd>-m</cmd> <cmd>pytest</cmd> <path>tests/</path>

Input: "Error at src/server.py:142 — connection refused. Port 8080 used by nginx
       (PID 1234). Fix: kill or change port."
Output:
  ✘ <b>Error in <path>src/server.py</path> line <meta>142</meta>:</b>
    <err>connection refused</err>

  <b>Cause:</b> port <meta>8080</meta> in use by <cmd>nginx</cmd> (PID <meta>1234</meta>)

  <b>Fix:</b>
    <cmd>kill</cmd> <meta>1234</meta>
    — or — change port in <path>src/server.py</path>

Input: "Changes: added auth.py, modified config.py, deleted old_parser.py"
Output:
  ┌──────────┬──────────────────┐
  │ <b>Change</b>   │ <b>File</b>             │
  ├──────────┼──────────────────┤
  │ <ok>+ add</ok>    │ <path>auth.py</path>      │
  │ <warn>~ mod</warn>    │ <path>config.py</path>    │
  │ <err>- del</err>    │ <path>old_parser.py</path> │
  └──────────┴──────────────────┘

Input: "Setup: 1. Clone repo 2. pip install -r requirements.txt 3. Copy
       .env.example to .env 4. Run python3 main.py"
Output:
  <b>Setup</b>

  <b>1.</b> Clone the repository
  <b>2.</b> <cmd>pip</cmd> <cmd>install</cmd> <cmd>-r</cmd> <path>requirements.txt</path>
  <b>3.</b> Copy <path>.env.example</path> → <path>.env</path>
  <b>4.</b> <cmd>python3</cmd> <path>main.py</path>

━━━ EDGE CASES ━━━

E1. Long lines (>200 chars): break at boundaries, indent continuation +4.
E2. Mixed content: separate with blank lines.
E3. Already-formatted input: preserve existing tags, fix structural issues.
E4. Non-English: preserve as-is, never translate.
E5. URLs/emails: plain, own line if long.
E6. Terminal: 80-120 cols. Optimize for 80.
E7. Empty input: single blank line.
E8. Pre-existing ANSI: strip OUTSIDE <code>. INSIDE <code>: PRESERVE
    (these are agent-generated syntax highlights).

E9. Emoji width: pictograph emoji (📄📁🔍💡⭐🐛🔧📝🚀⏳📊🔗) occupy ~2
    terminal columns. Subtract 2 from available width when a line starts
    with one. Symbols (✔✘⚠ℹ) are 1 column wide. When an emoji-prefixed
    line nears the width limit, break earlier to avoid wrap misalignment.

AGEF
BSRP_TEMPLATE="$FORMAT_AGENT_BODY"
_bsrp_assemble  # fill __COLOR_SECTION__ with active mode
FORMAT_AGENT_META='{"name":"format","description":"bashagt Standard Rendering Protocol engine","max_tokens":32768,"thinking_budget":0,"tools":[]}'

# Convert BSRP semantic tags to ANSI. <code> blocks pass through unchanged.
_fmt_postprocess() {
    local _esc; _esc=$'\033'
    sed \
        -e "s|"$'\r'"||g" \
        -e "s|\\\\033|$_esc|g" \
        -e "s|<code>||g; s|</code>||g" \
        -e "s|<b>|${BOLD}|g" \
        -e "s|<dim>|${DIM}|g" \
        -e "s|<ok>|${OK_COLOR}|g" \
        -e "s|<err>|${ERR_COLOR}|g" \
        -e "s|<warn>|${WARN_COLOR}|g" \
        -e "s|<path>|${PATH_COLOR}|g" \
        -e "s|<cmd>|${CMD_COLOR}|g" \
        -e "s|<meta>|${META_COLOR}|g" \
        -e "s|<sel>|${SEL_COLOR}|g" \
        -e "s|<var>|${VAR}|g" \
        -e "s|</\(b\|dim\|ok\|err\|warn\|path\|cmd\|meta\|sel\|var\)>|${RESET}|g"
}

# Fork-free variant.
_fmt_postprocess_var() {
    local _s="$1" _outvar="$2" _esc; _esc=$'\033'
    _s="${_s//$'\r'/}"
    _s="${_s//\\033/$_esc}"
    _s="${_s//<code>/}"; _s="${_s//<\/code>/}"
    _s="${_s//<b>/${BOLD}}";       _s="${_s//<\/b>/${RESET}}"
    _s="${_s//<dim>/${DIM}}";      _s="${_s//<\/dim>/${RESET}}"
    _s="${_s//<ok>/${OK_COLOR}}";  _s="${_s//<\/ok>/${RESET}}"
    _s="${_s//<err>/${ERR_COLOR}}"; _s="${_s//<\/err>/${RESET}}"
    _s="${_s//<warn>/${WARN_COLOR}}"; _s="${_s//<\/warn>/${RESET}}"
    _s="${_s//<path>/${PATH_COLOR}}"; _s="${_s//<\/path>/${RESET}}"
    _s="${_s//<cmd>/${CMD_COLOR}}"; _s="${_s//<\/cmd>/${RESET}}"
    _s="${_s//<meta>/${META_COLOR}}"; _s="${_s//<\/meta>/${RESET}}"
    _s="${_s//<sel>/${SEL_COLOR}}"; _s="${_s//<\/sel>/${RESET}}"
    _s="${_s//<var>/${VAR}}";      _s="${_s//<\/var>/${RESET}}"
    printf -v "$_outvar" '%s' "$_s"
}

# JSON unescape for SSE text extraction.
# Handles \\ vs \n ordering via SOH placeholder to avoid corrupting \\n sequences.
_json_unescape() {
    local _soh=$'\x01' _raw="$1" _outvar="$2" _bs
    printf -v _bs '\\'                     # single backslash via printf (avoids \} parse risk)
    _raw="${_raw//$'\\\\'/$_soh}"          # \\ → SOH (protect from \n replacement)
    _raw="${_raw//'\r\n'/$'\n'}"           # CRLF → LF
    _raw="${_raw//'\r'/$'\n'}"             # CR → LF
    _raw="${_raw//'\n'/$'\n'}"             # LF → LF
    _raw="${_raw//'\t'/$'\t'}"             # TAB
    _raw="${_raw//$'\\\"'/'"'}"            # \" → "
    _raw="${_raw//$_soh/$_bs}"             # SOH → \ (variable avoids \} parse error)
    printf -v "$_outvar" '%s' "$_raw"
}

# ── Format streaming state (SSE callbacks run via lastpipe in parent shell) ──
_FMT_LINEBUF=""   # line buffer: accumulate text deltas, flush on \n
_FMT_OUTFD=1       # output file descriptor
_FMT_PREFIX=""     # line prefix (e.g. "  " for plan display indent)
# Deferred status_done: emitted on first format token (after 0.2s pause)
_FMT_DONE_LABEL="" _FMT_START_TS=0 _FMT_DONE_ITOK=0 _FMT_DONE_OTOK=0

# ── Format SSE callback (Anthropic protocol) ──
# Matches _sse_api_callback pattern: \r\n → \x01 escape → restore as \n
_fmt_stream_callback() {
    local _data="$2"
    [[ "$_data" == "{"* ]] || return 0

    local _text="" _from_jq=0
    if command -v jq >/dev/null 2>&1; then
        # jq -j (no trailing newline) + printf X sentinel prevents $() from
        # stripping trailing newlines that are part of the text content.
        local _data_size=${#_data}
        if (( _data_size > 10000 )); then
            log "DEBUG: [FMT] large SSE data size=$_data_size pid=$$"
        fi
        log "DEBUG: [FMT] before jq pid=$$"  # if this appears but not "after jq", $() is hung
        _text=$(printf '%s' "$_data" | jq -j '.delta.text // .delta.content // empty' 2>/dev/null; printf 'X')
        log "DEBUG: [FMT] after jq pid=$$"
        if [[ "$_text" != "X" ]]; then
            _text="${_text%X}"
            _from_jq=1
        else
            _text=""
        fi
    fi
    if [[ "$_from_jq" == "0" ]]; then
        [[ "$_data" == *'"delta":{"type":"text_delta","text":"'* ]] || return 0
        _text="${_data##*'"delta":{"type":"text_delta","text":"'}"
        [[ "$_text" == "$_data" ]] && return 0
        _text="${_text%%'"}}'*}"
    fi
    [[ -z "$_text" ]] && return 0
    _json_unescape "$_text" _text
    [[ -z "$_text" ]] && return 0

    _FMT_LINEBUF+="$_text"
    while [[ "$_FMT_LINEBUF" == *$'\n'* ]]; do
        local _line="${_FMT_LINEBUF%%$'\n'*}"
        _FMT_LINEBUF="${_FMT_LINEBUF#*$'\n'}"
        # Deferred status_done on first format token (0.2s beat before text)
        if [[ -n "${_FMT_DONE_LABEL:-}" ]]; then
            local _ela; _now_ms; _ui_time $(( _NOW_MS - _FMT_START_TS )); _ela="$_UI_TIME"
            if [[ "$_FMT_OUTFD" == "8" ]]; then
                _stream_kv status_done \
                    label "$_FMT_DONE_LABEL" elapsed_str "$_ela" \
                    tokens_in "${_FMT_DONE_ITOK:-0}" tokens_out "${_FMT_DONE_OTOK:-0}" >&8
            else
                local _d; _d="${_FMT_DONE_LABEL} ($_ela) $(ui_tokens ${_FMT_DONE_ITOK:-0} ${_FMT_DONE_OTOK:-0})"
                status_done "$_d"
            fi
            _FMT_DONE_LABEL=""
            sleep 0.2
        fi
        local _fmt; _fmt_postprocess_var "$_line" _fmt
        if [[ "$_FMT_OUTFD" == "8" ]]; then
            _stream_text "$_fmt" >&8
        else
            printf '%s%s\n' "${_FMT_PREFIX:-}" "$_fmt" >&"$_FMT_OUTFD"
        fi
    done
}

# ── Format SSE callback (OpenAI protocol) ──
_fmt_stream_callback_openai() {
    local _data="$2"
    [[ "$_data" == "[DONE]" ]] && return 0
    [[ "$_data" == "{"* ]] || return 0

    local _text
    [[ "$_data" == *'"content":"'* ]] || return 0
    _text="${_data##*'"content":"'}"
    [[ "$_text" == "$_data" ]] && return 0
    _text="${_text%%'"}'*}"
    _json_unescape "$_text" _text
    [[ -z "$_text" ]] && return 0

    _FMT_LINEBUF+="$_text"
    while [[ "$_FMT_LINEBUF" == *$'\n'* ]]; do
        local _line="${_FMT_LINEBUF%%$'\n'*}"
        _FMT_LINEBUF="${_FMT_LINEBUF#*$'\n'}"
        # Deferred status_done on first format token (0.2s beat before text)
        if [[ -n "${_FMT_DONE_LABEL:-}" ]]; then
            local _ela; _now_ms; _ui_time $(( _NOW_MS - _FMT_START_TS )); _ela="$_UI_TIME"
            if [[ "$_FMT_OUTFD" == "8" ]]; then
                _stream_kv status_done \
                    label "$_FMT_DONE_LABEL" elapsed_str "$_ela" \
                    tokens_in "${_FMT_DONE_ITOK:-0}" tokens_out "${_FMT_DONE_OTOK:-0}" >&8
            else
                local _d; _d="${_FMT_DONE_LABEL} ($_ela) $(ui_tokens ${_FMT_DONE_ITOK:-0} ${_FMT_DONE_OTOK:-0})"
                status_done "$_d"
            fi
            _FMT_DONE_LABEL=""
            sleep 0.2
        fi
        local _fmt; _fmt_postprocess_var "$_line" _fmt
        if [[ "$_FMT_OUTFD" == "8" ]]; then
            _stream_text "$_fmt" >&8
        else
            printf '%s%s\n' "${_FMT_PREFIX:-}" "$_fmt" >&"$_FMT_OUTFD"
        fi
    done
}

# Format text via the format sub-agent and stream to a file descriptor.
# Uses SSE (http_sse_connect) for progressive output — first delta typically <2s.
# Usage: format_text_stream <raw> <chars> <term_width> [out_fd] [prefix]
format_text_stream() {
    local raw="$1" chars=$2 term_width="${3:-$TERM_WIDTH}" out_fd="${4:-}" prefix="${5:-}"

    # ── Config / threshold checks ──
    [[ "${BASHAGT_FORMAT_SUBAGENT:-true}" != "true" ]] && { printf '%s' "$raw"; return 1; }

    [[ -z "${AGENTS[format]:-}" ]] && { printf '%s' "$raw"; return 1; }

    # ── Resolve output fd ──
    local _fd
    if [[ -n "$out_fd" ]]; then
        _fd="$out_fd"
    elif [[ -e /proc/self/fd/8 || -e /dev/fd/8 ]]; then
        _fd=8
    elif [[ -w /dev/tty ]]; then
        _fd="/dev/tty"
    else
        _fd=1
    fi

    # ── Profile resolution (same pattern as _call_agent_core) ──
    local meta="${AGENT_META[format]:-}"
    local model max_tokens
    model=$(jq -r '(.model // "")' <<< "$meta" 2>/dev/null)
    max_tokens=$(jq -r '(.max_tokens // 4096)' <<< "$meta" 2>/dev/null)

    local _agent_profile _saved_prof="$_PROF_NAME"
    _agent_profile=$(jq -r '(.profile // "")' <<< "$meta" 2>/dev/null)
    [[ -z "$_agent_profile" || "$_agent_profile" == "null" ]] && _agent_profile="${BASHAGT_MAIN_PROFILE:-}"
    _resolve_profile "$_agent_profile" || _resolve_profile ""

    local _meta_model; _meta_model=$(jq -r '(.model // "")' <<< "$meta" 2>/dev/null)
    if [[ -n "$_meta_model" && "$_meta_model" != "null" ]]; then
        model="$_meta_model"
    elif [[ -z "$model" || "$model" == "null" ]]; then
        model=$(_prof_get_field model)
    fi

    local _sa_url _sa_proto _sa_auth_h _sa_auth_px _sa_key
    _sa_url=$(_prof_get_field api_url)
    _sa_proto=$(_prof_get_field protocol)
    _sa_auth_h=$(_prof_get_field auth_header)
    _sa_auth_px=$(_prof_get_field auth_prefix)
    _sa_key=$(_prof_get_field api_key)

    local system_prompt="Your agent name: format
Other running agents: none
Current working directory: $(pwd)
${AGENTS[format]}"

    # ── Build user prompt ──
    local _prompt; _prompt="Terminal width: ${term_width} columns. Format the ENTIRE text below completely — do NOT omit, summarize, or truncate any content. Output the full formatted result.
━━━ BEGIN RAW TEXT ━━━
${raw}
━━━ END RAW TEXT ━━━"

    local _msgs; _msgs=$(jq -nc --arg prompt "$_prompt" '[{role:"user",content:$prompt}]')

    # Override max_tokens with config value
    local _fmt_mt="${BASHAGT_FORMAT_MAX_TOKENS:-24576}"
    max_tokens="$_fmt_mt"

    local request_body; request_body=$(build_request_body "$model" "$max_tokens" \
        "$system_prompt" "$_msgs" "[]" "0" "true")
    log "DEBUG: [FMT] SSE: model=$model mt=$max_tokens url=$_sa_url body=${#request_body}"

    # ── Set global state for callback ──
    _FMT_LINEBUF=""
    _FMT_OUTFD="$_fd"
    _FMT_PREFIX="${prefix:-}"
    _FMT_SPIN_TICK_CNT=0

    # ── Spinner tick for SSE polling phase (keeps timer alive during format HTTP) ──
    _fmt_spin_tick() {
        [[ ${_FMT_START_TS:-0} -eq 0 ]] && return 0
        [[ -z "${_FMT_DONE_LABEL:-}" ]] && return 0
        (( _FMT_SPIN_TICK_CNT++ % 2 != 0 )) && return 0
        local _now; _now=$(_timestamp_ms)
        local _e=$(( _now - _FMT_START_TS ))
        (( _e < 0 )) && _e=0
        _spin_tick
        _ui_time $_e
        _stream_kv status_update icon "$_SPIN_FRAME" label "Thinking..." elapsed_str "$_UI_TIME" >&8
    }

    # ── SSE streaming ──
    local _callback _av_header=()
    if [[ "$_sa_proto" == "openai" ]]; then
        _callback="_fmt_stream_callback_openai"
    else
        _callback="_fmt_stream_callback"
        _av_header=(--header "anthropic-version: 2023-06-01")
    fi

    http_sse_connect "$_sa_url" "$_callback" \
        --connect-timeout "${BASHAGT_CONNECT_TIMEOUT:-10}" --max-time 120 \
        --spin-callback _fmt_spin_tick \
        "${_av_header[@]}" \
        --header "content-type: application/json" \
        --auth-header "$_sa_auth_h" --auth-value "${_sa_auth_px}${_sa_key}" \
        --body "$request_body" \
        --err-file /dev/null
    local _rc=$?

    # ── Flush remaining line buffer ──
    if [[ -n "$_FMT_LINEBUF" ]]; then
        # Deferred status_done (if not emitted by while loop — e.g. single-line output)
        if [[ -n "${_FMT_DONE_LABEL:-}" ]]; then
            local _ela; _now_ms; _ui_time $(( _NOW_MS - _FMT_START_TS )); _ela="$_UI_TIME"
            if [[ "$_FMT_OUTFD" == "8" ]]; then
                _stream_kv status_done \
                    label "$_FMT_DONE_LABEL" elapsed_str "$_ela" \
                    tokens_in "${_FMT_DONE_ITOK:-0}" tokens_out "${_FMT_DONE_OTOK:-0}" >&8
            else
                local _d; _d="${_FMT_DONE_LABEL} ($_ela) $(ui_tokens ${_FMT_DONE_ITOK:-0} ${_FMT_DONE_OTOK:-0})"
                status_done "$_d"
            fi
            _FMT_DONE_LABEL=""
            sleep 0.2
        fi
        local _fmt; _fmt_postprocess_var "$_FMT_LINEBUF" _fmt
        if [[ "$_FMT_OUTFD" == "8" ]]; then
            _stream_text "$_fmt" >&8
        else
            printf '%s%s\n' "${prefix:-}" "$_fmt" >&"$_FMT_OUTFD"
        fi
    fi

    _resolve_profile "$_saved_prof" || _resolve_profile ""

    if [[ $_rc -ne 0 ]]; then
        log "DEBUG: [FMT] SSE failed: rc=$_rc"
        printf '%s' "$raw"
        return 1
    fi
    return 0
}

# ============================================================================
# SECTION 7b: Skill System + Background Tasks
# ============================================================================

declare -A SKILLS SKILL_META
ACTIVE_SKILLS=()
TASK_DIR="${BASHAGT_TMPDIR:-/tmp}/bashagt_tasks_$$"

# Parse a skill.md file. Optional $2 overrides the name (directory name takes
# precedence over JSON frontmatter "name" field). Sets SKILLS[$name] and SKILL_META[$name].
parse_skill_file() {
    local file="$1" opt_name="${2:-}"
    local raw name meta_json body in_marker
    raw=$(<"$file")
    local first_line; first_line=$(head -1 <<< "$raw")
    if [[ "$first_line" == "{"*"}" ]]; then
        # Single-line JSON frontmatter
        meta_json="$first_line"
        body=$(printf '%s' "$raw" | tail -n +2 | sed '/./,$!d')
    elif [[ "$first_line" == "---" ]]; then
        # YAML frontmatter (--- ... ---)
        meta_json=""; in_marker=0
        while IFS= read -r line; do
            if [[ "$line" == "---" ]]; then
                (( in_marker++ ))
                [[ $in_marker -eq 2 ]] && break
                continue
            fi
            if [[ $in_marker -eq 1 ]]; then
                meta_json+="$line"$'\n'
            fi
        done <<< "$raw"
        # Convert simple YAML key: value pairs to JSON
        meta_json=$(printf '%s' "$meta_json" | jq -Rn '
            [inputs | select(length > 0)]
            | map(
                capture("^(?<key>[a-zA-Z_][a-zA-Z0-9_]*):[[:space:]]*(?<value>.+)$")
                | .value |= (sub("^\"(?<v>.*)\"$";"\(.v)") | sub("^'"'"'(?<v>.*)'"'"'$";"\(.v)"))
            )
            | from_entries
        ' 2>/dev/null)
        [[ -z "$meta_json" || "$meta_json" == "{}" ]] && meta_json=""
        body=$(printf '%s\n' "$raw" | awk 'BEGIN{c=0} /^---$/{c++; next} c>=2{print}' | sed '/./,$!d')
    else
        # Multi-line JSON frontmatter
        meta_json=""; in_marker=0
        while IFS= read -r line; do
            if [[ "$line" == "{" ]] && (( in_marker == 0 )); then
                in_marker=1; meta_json+="$line"$'\n'
            elif (( in_marker == 1 )); then
                meta_json+="$line"$'\n'
                [[ "$line" == "}" ]] && break
            fi
        done <<< "$raw"
        body=$(printf '%s' "$raw" | sed "1,/^}/d" | sed '/./,$!d')
    fi
    # Directory name takes priority; fall back to JSON "name" field
    if [[ -n "$opt_name" ]]; then
        name="$opt_name"
    else
        name=$(echo "$meta_json" | jq -r '.name // empty' 2>/dev/null)
    fi
    [[ -z "$name" ]] && return 1
    SKILLS[$name]="$body"
    SKILL_META[$name]="$meta_json"
}

load_skills() {
    SKILLS=(); SKILL_META=()
    local dir skill_md name
    # System skills first (lower priority)
    for dir in "$HOME/.bashagt/skills/"*/; do
        skill_md="${dir}skill.md"
        [[ -f "$skill_md" ]] || skill_md="${dir}SKILL.md"
        [[ -f "$skill_md" ]] && name=$(basename "$dir") && parse_skill_file "$skill_md" "$name" || true
    done
    # Project skills (cannot override system by name)
    for dir in .bashagt/skills/*/; do
        skill_md="${dir}skill.md"
        [[ -f "$skill_md" ]] || skill_md="${dir}SKILL.md"
        if [[ -f "$skill_md" ]]; then
            name=$(basename "$dir")
            if [[ -n "${SKILLS[$name]:-}" ]]; then
                log "System skill '$name' cannot be overridden by project skill"
                continue
            fi
            parse_skill_file "$skill_md" "$name" || true
        fi
    done
    # Opt-out model: all skills active by default; /skill-off to remove
    ACTIVE_SKILLS=("${!SKILLS[@]}")
    log "DEBUG: SKILLS_LOAD count=${#SKILLS[@]}"
    return 0
}
activate_skill() {
    local name="$1"
    [[ -n "${SKILLS[$name]:-}" ]] || { printf '  Skill "%s" not found.\n' "$name"; return 1; }
    for s in "${ACTIVE_SKILLS[@]}"; do [[ "$s" == "$name" ]] && { printf '  Skill "%s" already active.\n' "$name"; return 0; }; done
    ACTIVE_SKILLS+=("$name")
    printf '  Skill "%s" re-enabled.\n' "$name"
}

deactivate_skill() {
    local name="$1" new_list=()
    for s in "${ACTIVE_SKILLS[@]}"; do [[ "$s" != "$name" ]] && new_list+=("$s"); done
    ACTIVE_SKILLS=("${new_list[@]}")
    printf '  Skill "%s" deactivated.\n' "$name"
}

# Background task management
init_task_dir() { mkdir -p "$TASK_DIR"; }
list_tasks() {
    local count=0 _out=''
    _out+=$(ui_label 'BACKGROUND TASKS' bold)$'\n'
    for f in "$TASK_DIR"/*.json; do
        [[ -f "$f" ]] || continue
        count=$((count+1))
        local id status name created
        id=$(basename "$f" .json)
        status=$(jq -r '.status // "unknown"' "$f" 2>/dev/null)
        name=$(jq -r '.name // "?"' "$f" 2>/dev/null)
        _out+=$(printf '  [%s] %s — %s' "$id" "$name" "$status")$'\n'
    done
    (( count == 0 )) && _out+='  No background tasks.'$'\n'
    _stream_emit "text" "$(jq -nc --arg c "$_out" '{content: $c}')"
}
task_cancel() { local f="$TASK_DIR/${1}.json"; [[ -f "$f" ]] || return; rm -f "$f" "${BASHAGT_TMPDIR:-/tmp}/bashagt_${1}.out"; _stream_emit "text" "$(jq -nc --arg c "  Task $1 cancelled." '{content: $c}')"; }

# ============================================================================
# SECTION 7c: Memory Network — distributed weak-vector agent memory
# ============================================================================
# Engram memory network: N engrams × M slots = total capacity
# Write: free-slot-first. Sleep phase at write_rate ≥ 85%.

# _set_project_paths() in main() sets the authoritative value.
MEM_NET_DIR=""
MEMORY_POOL=""          # JSON array of all summaries (cached, 30s TTL)
MEMORY_CACHE_TS=0
MEM_SLOT_TABLE=""       # path to slot_table.json

# Init slot table (N engrams × M slots)
_mem_init_slot_table() {
    local table="$1"
    local _i _engrams_json=""
    for (( _i=0; _i<MEM_ENGRAM_COUNT; _i++ )); do
        local _eid; _eid=$(printf 'engram_%02d' "$_i")
        [[ -n "$_engrams_json" ]] && _engrams_json+=","
        _engrams_json+="\"$_eid\":{\"total\":$MEM_ENGRAM_SLOTS,\"used\":0,\"free\":$MEM_ENGRAM_SLOTS,\"last_write\":0}"
    done
    jq -n "{
        engrams: {${_engrams_json}},
        global: {
            total_capacity: $MEM_TOTAL_CAPACITY,
            total_used: 0,
            total_free: $MEM_TOTAL_CAPACITY,
            write_rate: 0,
            sleep_phase: false,
            last_sleep_phase: 0
        }
    }" > "$table"
}

# Pick engram with most free slots (ties: oldest last_write)

# Current write rate (0.0-1.0)
_mem_write_rate() {
    local table="${MEM_SLOT_TABLE:-.bashagt/mem_net/slot_table.json}"
    jq -r '.global.write_rate // 0' "$table" 2>/dev/null || echo "0"
}

# Update slot table after write/delete

# ── Model Profile Routing ──

# Infer provider name from API URL
_infer_provider() {
    local url="${1:-}"
    case "$url" in
        *deepseek*)     printf 'deepseek' ;;
        *anthropic*)    printf 'anthropic' ;;
        *openai*)       printf 'openai' ;;
        *openrouter*)   printf 'openrouter' ;;
        *)  if [[ -n "$url" ]]; then
                local _d; _d="${url#*://}"; _d="${_d%%/*}"; _d="${_d#api.}"
                printf '%s' "$_d"
            else
                printf 'unknown'
            fi ;;
    esac
}

# Compute deterministic hash of current model configuration
_compute_model_hash() {
    {
        echo "model=$BASHAGT_MODEL"
        echo "api_url=$BASHAGT_API_URL"
        echo "api_protocol=${BASHAGT_PROTOCOL:-anthropic}"
        local _sorted; _sorted=$(printf '%s\n' "${!MODEL_PROFILES[@]}" | sort)
        local _k
        while IFS= read -r _k; do
            [[ -z "$_k" ]] && continue
            echo "profile[$_k]=${MODEL_PROFILES[$_k]}"
        done <<< "$_sorted"
    } | _cc_hash
}

# Load model profiles from settings.json into MODEL_PROFILES associative array
_load_model_profiles() {
    MODEL_PROFILES=()
    local profiles_json
    profiles_json=$(_get_setting "model_profiles" "BASHAGT_MODEL_PROFILES" "{}")
    [[ -z "$profiles_json" || "$profiles_json" == "{}" || "$profiles_json" == "null" ]] && return 0

    while IFS= read -r name; do
        [[ -z "$name" ]] && continue
        local cfg
        cfg=$(echo "$profiles_json" | jq -c --arg n "$name" '.[$n] // empty' 2>/dev/null)
        [[ -n "$cfg" && "$cfg" != "null" ]] && MODEL_PROFILES[$name]="$cfg"
    done < <(echo "$profiles_json" | jq -r 'keys[]' 2>/dev/null)

    log "DEBUG: [PROFILE] loaded ${#MODEL_PROFILES[@]} profiles: ${!MODEL_PROFILES[*]}"
}

# Resolve a named profile. Sets _PROF_* globals. Returns 0 on success, 1 if not found.
# name="" or "default" clears _PROF_* (use flat BASHAGT_* globals).
_resolve_profile() {
    local name="${1:-}"

    # "default" or empty = use flat keys, clear profile state
    if [[ -z "$name" || "$name" == "default" ]]; then
        _PROF_NAME=""; _PROF_MODEL=""; _PROF_API_URL=""; _PROF_API_KEY=""
        _PROF_AUTH_HEADER=""; _PROF_AUTH_PREFIX=""; _PROF_PROTOCOL=""
        _PROF_MAX_TOKENS=""; _PROF_THINKING_BUDGET=""
        return 0
    fi

    # Cache hit
    [[ "$name" == "$_PROF_NAME" ]] && return 0

    # Look up profile
    local cfg="${MODEL_PROFILES[$name]:-}"
    [[ -z "$cfg" ]] && { log "WARN: profile '$name' not found in model_profiles"; return 1; }

    _PROF_NAME="$name"
    _PROF_MODEL=$(echo "$cfg" | jq -r ".model // \"$BASHAGT_MODEL\"" 2>/dev/null)
    _PROF_API_URL=$(echo "$cfg" | jq -r ".api_url // \"$BASHAGT_API_URL\"" 2>/dev/null)
    _PROF_API_KEY=$(echo "$cfg" | jq -r ".api_key // \"$BASHAGT_API_KEY\"" 2>/dev/null)
    _PROF_MAX_TOKENS=$(echo "$cfg" | jq -r ".max_tokens // \"$BASHAGT_MAX_TOKENS\"" 2>/dev/null)
    _PROF_THINKING_BUDGET=$(echo "$cfg" | jq -r ".thinking_budget // \"$BASHAGT_THINKING_BUDGET\"" 2>/dev/null)

    # Resolve protocol (explicit per-profile or auto-detect from profile URL)
    local _explicit_proto
    _explicit_proto=$(echo "$cfg" | jq -r '.api_protocol // "auto"' 2>/dev/null)
    if [[ -n "$_explicit_proto" && "$_explicit_proto" != "auto" && "$_explicit_proto" != "null" ]]; then
        _PROF_PROTOCOL="$_explicit_proto"
    else
        case "$_PROF_API_URL" in
            */anthropic/*|*anthropic.com*)  _PROF_PROTOCOL="anthropic" ;;
            */openai/*|*/v1/chat/completions*|*openai.com*) _PROF_PROTOCOL="openai" ;;
            *deepseek*) _PROF_PROTOCOL="anthropic" ;;
            *) _PROF_PROTOCOL="${BASHAGT_PROTOCOL:-anthropic}" ;;
        esac
    fi

    # Resolve auth header (explicit or auto-detect from protocol + URL)
    local _explicit_auth
    _explicit_auth=$(echo "$cfg" | jq -r '.auth_header // ""' 2>/dev/null)
    if [[ -n "$_explicit_auth" && "$_explicit_auth" != "null" ]]; then
        _PROF_AUTH_HEADER="$_explicit_auth"
        [[ "$_explicit_auth" == "Authorization" ]] && _PROF_AUTH_PREFIX="Bearer " || _PROF_AUTH_PREFIX=""
    else
        if [[ "$_PROF_PROTOCOL" == "openai" ]]; then
            _PROF_AUTH_HEADER="Authorization"; _PROF_AUTH_PREFIX="Bearer "
        elif [[ "$_PROF_API_URL" == *deepseek* ]]; then
            _PROF_AUTH_HEADER="Authorization"; _PROF_AUTH_PREFIX="Bearer "
        elif [[ "$_PROF_API_URL" == *anthropic* ]]; then
            _PROF_AUTH_HEADER="x-api-key"; _PROF_AUTH_PREFIX=""
        else
            _PROF_AUTH_HEADER="${BASHAGT_AUTH_HEADER:-x-api-key}"
            _PROF_AUTH_PREFIX="${BASHAGT_AUTH_PREFIX:-}"
        fi
    fi

    [[ -z "$_PROF_API_KEY" ]] && { log "WARN: profile '$name' has no api_key"; return 1; }

    log "DEBUG: [PROFILE] resolved=$name model=$_PROF_MODEL url=$_PROF_API_URL protocol=$_PROF_PROTOCOL"
    return 0
}

# Get active value for a profile field, falling back to BASHAGT_* flat key.
# Usage: model=$(_prof_get_field model); url=$(_prof_get_field api_url)
_prof_get_field() {
    case "$1" in
        model)      [[ -n "${_PROF_MODEL:-}" ]] && printf '%s' "$_PROF_MODEL" || printf '%s' "$BASHAGT_MODEL" ;;
        api_url)    [[ -n "${_PROF_API_URL:-}" ]] && printf '%s' "$_PROF_API_URL" || printf '%s' "$BASHAGT_API_URL" ;;
        api_key)    [[ -n "${_PROF_API_KEY:-}" ]] && printf '%s' "$_PROF_API_KEY" || printf '%s' "$BASHAGT_API_KEY" ;;
        auth_header) [[ -n "${_PROF_AUTH_HEADER:-}" ]] && printf '%s' "$_PROF_AUTH_HEADER" || printf '%s' "$BASHAGT_AUTH_HEADER" ;;
        auth_prefix) [[ -n "${_PROF_AUTH_PREFIX:-}" ]] && printf '%s' "$_PROF_AUTH_PREFIX" || printf '%s' "$BASHAGT_AUTH_PREFIX" ;;
        protocol)   [[ -n "${_PROF_PROTOCOL:-}" ]] && printf '%s' "$_PROF_PROTOCOL" || printf '%s' "$BASHAGT_PROTOCOL" ;;
        max_tokens) [[ -n "${_PROF_MAX_TOKENS:-}" ]] && printf '%s' "$_PROF_MAX_TOKENS" || printf '%s' "$BASHAGT_MAX_TOKENS" ;;
        thinking_budget) [[ -n "${_PROF_THINKING_BUDGET:-}" ]] && printf '%s' "$_PROF_THINKING_BUDGET" || printf '%s' "$BASHAGT_THINKING_BUDGET" ;;
        name)       printf '%s' "${_PROF_NAME:-default}" ;;
        *)          printf '' ;;
    esac
}

# ── Model Pool System ──

# Rebuild model_pool.json in background. Collects model names from profiles + flat config,
# calls main LLM API for detailed descriptions, merges user overrides, writes output.
_rebuild_model_pool() {
    local _pool_file="$HOME/.bashagt/model_pool.json"
    local _lock_file="$HOME/.bashagt/model_pool.lock"

    # mkdir lock to prevent concurrent rebuilds
    _lock_acquire_nb "$_lock_file" || return 0

    log "DEBUG: [MPOOL] rebuilding model_pool..."

    # 1. Collect unique model names from profiles + flat keys
    local _model_names=() _seen="" _n _m _cfg _url
    for _n in "${!MODEL_PROFILES[@]}"; do
        _cfg="${MODEL_PROFILES[$_n]}"
        _m=$(echo "$_cfg" | jq -r '.model // ""' 2>/dev/null)
        [[ -z "$_m" || "$_m" == "null" || "$_seen" == *"|$_m|"* ]] && continue
        _model_names+=("$_m")
        _seen+="|$_m|"
    done
    if [[ "$_seen" != *"|$BASHAGT_MODEL|"* ]]; then
        _model_names+=("$BASHAGT_MODEL")
    fi
    [[ ${#_model_names[@]} -eq 0 ]] && { _lock_release "$_lock_file"; return 0; }

    # 2. Build query prompt and call main LLM API for descriptions
    local _prompt _joined; _joined="${_model_names[*]}"
    _prompt=$(jq -nc --arg models "$_joined" '
        "Describe the following AI models. For each, provide:\n" +
        "1. A concise description (1-2 sentences summarizing capabilities)\n" +
        "2. Best use cases (array: what tasks it excels at — coding, planning, summarization, search, formatting, memory, etc.)\n" +
        "3. Strengths (array: 2-3 key advantages)\n" +
        "4. Weaknesses (array: 1-2 limitations)\n" +
        "5. Whether it supports extended thinking/reasoning (extended_thinking: true/false)\n" +
        "6. Approximate context window size (number)\n\n" +
        "Models to describe: \($models)\n\n" +
        "Output ONLY a JSON object keyed by exact model name. No other text."')

    local _api_out _body _llm_desc_json="{}"
    _api_out=$(_mktemp_file /tmp/bashagt_mpool.XXXXXX)
    _body=$(jq -n --arg model "$BASHAGT_MODEL" --arg prompt "$_prompt" '{
        model: $model,
        messages: [{role: "user", content: $prompt}],
        max_tokens: 4096
    }')

    # Convert to OpenAI format if needed
    if [[ "${BASHAGT_PROTOCOL:-anthropic}" == "openai" ]]; then
        _body=$(echo "$_body" | _proto_convert_request 2>/dev/null || echo "$_body")
    fi

    local _av_arg=()
    [[ "${BASHAGT_PROTOCOL:-anthropic}" != "openai" ]] && _av_arg=(--header "anthropic-version: 2023-06-01")

    http_post "$BASHAGT_API_URL" "$_api_out" \
        --connect-timeout 10 --max-time 60 \
        --body "$_body" \
        --auth-header "$BASHAGT_AUTH_HEADER" --auth-value "${BASHAGT_AUTH_PREFIX}${BASHAGT_API_KEY}" \
        "${_av_arg[@]}" --header "content-type: application/json" 2>/dev/null || true

    if [[ -s "$_api_out" ]]; then
        if [[ "${BASHAGT_PROTOCOL:-anthropic}" == "openai" ]]; then
            _llm_desc_json=$(jq -r '.choices[0].message.content // "{}"' "$_api_out" 2>/dev/null)
        else
            _llm_desc_json=$(jq -r '.content[0].text // "{}"' "$_api_out" 2>/dev/null)
        fi
        # Try to extract JSON from response (may be wrapped in markdown code fences)
        if [[ "$_llm_desc_json" != "{"* ]]; then
            local _extracted
            _extracted=$(echo "$_llm_desc_json" | sed -n '/```json/,/```/p' | sed '1d;$d' 2>/dev/null)
            [[ -n "$_extracted" ]] && _llm_desc_json="$_extracted"
        fi
    fi
    rm -f "$_api_out"

    # 3. Build final models JSON: LLM description as base, user profile overrides on top
    local _final_models_json _m _mdesc _murl _mprovider _mprofiles _mtb _mctx _met
    _final_models_json="{}"
    for _m in "${_model_names[@]}"; do
        # Get LLM-generated description for this model
        _mdesc=$(echo "$_llm_desc_json" | jq -r --arg m "$_m" '.[$m].description // ""' 2>/dev/null)
        _mctx=$(echo "$_llm_desc_json" | jq -r --arg m "$_m" '.[$m].context_window // 0' 2>/dev/null)
        _met=$(echo "$_llm_desc_json" | jq -r --arg m "$_m" '.[$m].extended_thinking // false' 2>/dev/null)

        # Find profiles that use this model
        _mprofiles="[]" _murl="" _mtb=0
        for _n in "${!MODEL_PROFILES[@]}"; do
            _cfg="${MODEL_PROFILES[$_n]}"
            local _pm; _pm=$(echo "$_cfg" | jq -r '.model // ""' 2>/dev/null)
            if [[ "$_pm" == "$_m" ]]; then
                _mprofiles=$(echo "$_mprofiles" | jq --arg p "$_n" '. + [$p]' 2>/dev/null)
                [[ -z "$_murl" ]] && _murl=$(echo "$_cfg" | jq -r '.api_url // ""' 2>/dev/null)
                local _ptb; _ptb=$(echo "$_cfg" | jq -r '.thinking_budget // 0' 2>/dev/null)
                [[ "$_ptb" -gt 0 ]] && _mtb=$_ptb
                # User description override
                local _udesc; _udesc=$(echo "$_cfg" | jq -r '.description // ""' 2>/dev/null)
                [[ -n "$_udesc" && "$_udesc" != "null" ]] && _mdesc="$_udesc"
            fi
        done

        # Infer provider from the first profile's URL, or flat URL
        [[ -z "$_murl" ]] && _murl="$BASHAGT_API_URL"
        _mprovider=$(_infer_provider "$_murl")

        # Fallback description
        [[ -z "$_mdesc" || "$_mdesc" == "null" ]] && _mdesc="$_m ($_mprovider)"

        # Build use_cases/strengths/weaknesses from LLM or empty
        local _ucases _strs _weaks
        _ucases=$(echo "$_llm_desc_json" | jq -c --arg m "$_m" '.[$m].use_cases // []' 2>/dev/null)
        _strs=$(echo "$_llm_desc_json" | jq -c --arg m "$_m" '.[$m].strengths // []' 2>/dev/null)
        _weaks=$(echo "$_llm_desc_json" | jq -c --arg m "$_m" '.[$m].weaknesses // []' 2>/dev/null)
        [[ -z "$_ucases" || "$_ucases" == "null" ]] && _ucases="[]"
        [[ -z "$_strs" || "$_strs" == "null" ]] && _strs="[]"
        [[ -z "$_weaks" || "$_weaks" == "null" ]] && _weaks="[]"

        _final_models_json=$(echo "$_final_models_json" | jq \
            --arg m "$_m" --arg desc "$_mdesc" --arg provider "$_mprovider" \
            --argjson ctx "$_mctx" --argjson et "$_met" \
            --argjson profiles "$_mprofiles" \
            --argjson use_cases "$_ucases" \
            --argjson strengths "$_strs" \
            --argjson weaknesses "$_weaks" \
            '.[$m] = {
                description: $desc,
                provider: $provider,
                context_window: $ctx,
                extended_thinking: $et,
                profiles: $profiles,
                use_cases: $use_cases,
                strengths: $strengths,
                weaknesses: $weaknesses
            }' 2>/dev/null || echo "$_final_models_json")
    done

    # 4. Write model_pool.json atomically
    local _new_hash; _new_hash=$(_compute_model_hash)
    jq -n --arg hash "$_new_hash" --arg ts "$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S')" \
        --argjson models "$_final_models_json" \
        '{_config_hash: $hash, _updated_at: $ts, models: $models}' \
        > "$_pool_file.tmp" && mv "$_pool_file.tmp" "$_pool_file"

    log "DEBUG: model_pool rebuilt — ${#_model_names[@]} models, hash=$_new_hash"
    _lock_release "$_lock_file"
}

# Ensure model_pool.json exists and is up-to-date. Called from load_config().
_ensure_model_pool() {
    local _pool_file="$HOME/.bashagt/model_pool.json"
    local _cfg_file="${BASHAGT_PROJECT_SETTINGS:-$HOME/.bashagt/settings.json}"
    local _pool_mtime _cfg_mtime
    _pool_mtime=$(_file_mtime "$_pool_file")
    _cfg_mtime=$(_file_mtime "$_cfg_file")
    # Pool is fresh if it's newer than the config file
    if [[ -f "$_pool_file" ]] && (( _pool_mtime >= _cfg_mtime )); then
        return 0
    fi
    log "DEBUG: model config changed or pool missing, rebuilding model_pool..."
    (_rebuild_model_pool) &  # always async — first turn masks latency
}

# ── Engram Agent infrastructure ──

# Register engram agents programmatically (no .md files needed)
_register_engram_agents() {
    local _ei _eid _prompt _engram_model
    # Profile-aware engram model resolution
    local _engram_profile="${BASHAGT_ENGRAM_PROFILE:-${BASHAGT_MAIN_PROFILE:-}}"
    local _saved_prof="$_PROF_NAME"
    _resolve_profile "$_engram_profile" || true
    # Legacy: explicit mem_engram_model field overrides profile model
    _engram_model="${BASHAGT_MEM_ENGRAM_MODEL:-}"
    [[ -z "$_engram_model" ]] && _engram_model=$(_prof_get_field model)
    _resolve_profile "$_saved_prof" || _resolve_profile ""
    local _engram_dir="${BASHAGT_PROJECT_DIR:-.}/.bashagt/mem_net/engrams"
    # Single mkdir for all engram dirs (was 16 separate mkdir calls)
    local _dirs=()
    for (( _ei=0; _ei<MEM_ENGRAM_COUNT; _ei++ )); do
        _dirs+=("$_engram_dir/$(printf 'engram_%02d' $_ei)/mem")
    done
    mkdir -p "${_dirs[@]}"
    for (( _ei=0; _ei<MEM_ENGRAM_COUNT; _ei++ )); do
        _eid=$(printf 'engram_%02d' "$_ei")
        # Skip if already registered (idempotent)
        [[ -n "${AGENTS[$_eid]:-}" ]] && continue

        AGENTS[$_eid]="You are engram agent $_eid.
Storage: .bashagt/mem_net/engrams/$_eid/
  summaries.jsonl — memory index (max $MEM_ENGRAM_SLOTS lines)
  mem/%04d.md — content files

ON START: check_messages to read your inbox. Process ALL pending messages.

ACTION: store
  - .summary, .keywords, .category, .importance, .source, .content
  - Determine next free slot ID (NNNN): count files in mem/ with ls mem/ | wc -l, add 1, zero-pad to 4 digits
  - Write mem/NNNN.md with noclobber: set -C; cat > mem/NNNN.md 2>/dev/null || { echo "SLOT_COLLISION"; exit 1; }
    This prevents overwriting an existing file if the slot was taken by a concurrent write.
    Use this EXACT YAML frontmatter (then blank line, then content):
---
id: \"$_eid/NNNN\"
summary: \"<one-line>\"
keywords: [\"kw1\",\"kw2\",\"kw3\"]
category: \"<category>\"
importance: <1-10>
source: \"<manual|compress>\"
timestamp: <unix_seconds>
---
<content>
  - Append ONE LINE to summaries.jsonl. Use this EXACT format (keys are ABBREVIATED):
    {\"id\":\"$_eid/NNNN\",\"s\":\"summary text\",\"k\":[\"kw1\",\"kw2\"],\"c\":\"category\",\"i\":10,\"src\":\"manual\",\"t\":<unix_ts>,\"n\":1}
    Key legend: s=summary, k=keywords, c=category, i=importance, src=source, t=timestamp, n=access_count
    CRITICAL: always end the line with a newline (\n). Use echo to append, NEVER use printf without \n.
  - Update slot_table.json using bash: jq '<expr>' .bashagt/mem_net/slot_table.json > .bashagt/mem_net/slot_table.tmp && mv .bashagt/mem_net/slot_table.tmp .bashagt/mem_net/slot_table.json
    (ALWAYS use temp file + mv, NEVER > directly on slot_table.json)
  - Reply: send_message(sender, {status:\"stored\", id:\"$_eid/NNNN\"})

ACTION: search
  - Read ALL lines from summaries.jsonl
  - For each summary, judge semantic relevance to .query (score 0-1)
  - Return top-5 ONLY: [{id, score, reason}]
  - Output ONLY the JSON array, no other text

ACTION: sleep_collect
  - Return ALL memories as JSON: [{id, s, k, c, t, n, i, src, sz, content}]
  - Read content from mem/NNNN.md (strip YAML frontmatter)
  - Output ONLY the JSON array

ACTION: sleep_rebuild
  - .data is a JSON array of replacement memories
  - Clear current summaries.jsonl and rm mem/*
  - Write each memory: mem/NNNN.md + summaries line
  - Reply: send_message(sender, {status:\"rebuilt\", count:N})

ACTION: validate
  - .baseline is conversation history summary text
  - Each of your memories is a claim — judge against baseline
  - For each memory: {id, verdict:\"keep\"|\"obsolete\", validation_score:0.0-1.0, reason:\"short\"}
  - validation_score: how well the claim is supported by baseline (1.0 = fully supported)

ACTION: compress
  - .memories is [{id, s, target_bytes}]
  - For each: compress .s to approximately .target_bytes bytes
  - Preserve key decisions, file paths, error messages. Remove redundant narrative.
  - Return: [{id, compressed_summary}]
  - Output ONLY the JSON array

Current slot_table: \$(cat .bashagt/mem_net/slot_table.json 2>/dev/null || echo 'N/A')"

        AGENT_META[$_eid]="{\"name\":\"$_eid\",\"description\":\"Memory engram $_eid — $MEM_ENGRAM_SLOTS slots\",\"max_tokens\":2048,\"thinking_budget\":0,\"model\":\"$_engram_model\",\"tools\":[\"bash\",\"send_message\",\"check_messages\",\"read_file\",\"write_file\",\"list_files\"],\"discovers\":[\"mem_writer\",\"mem_searcher\"]}"
        AGENT_STATUS[$_eid]="idle"
    done
    _agent_refresh_discovery  # re-expand discovers now that engrams exist
}

# Dispatch: wake a single engram agent to process its inbox (sync)
_mem_dispatch_inbox() {
    local engram_id="$1"
    # Check sleep trigger before dispatch
    _mem_check_sleep_trigger
    if [[ -n "${AGENTS[$engram_id]:-}" ]]; then
        call_agent "$engram_id" "Process your inbox messages now." 2>/dev/null || true
    fi
}

# Dispatch: wake engrams with non-empty inboxes (serial to avoid slot allocation races)
_mem_dispatch_all() {
    local _comm_dir="${COMM_DIR:-.bashagt/comm}"
    local _ei _eid _inbox
    for (( _ei=0; _ei<MEM_ENGRAM_COUNT; _ei++ )); do
        _eid=$(printf 'engram_%02d' "$_ei")
        _inbox="$_comm_dir/${_eid}.jsonl"
        [[ -s "$_inbox" ]] || continue
        if [[ -n "${AGENTS[$_eid]:-}" ]]; then
            call_agent "$_eid" "Process your inbox messages now." >/dev/null 2>&1 || true
        fi
    done
}

# Refresh MEMORY_POOL cache (30s TTL)
_mem_refresh_cache() {
    local _now; _now=${EPOCHSECONDS:-$(date +%s)}
    if (( _now - MEMORY_CACHE_TS < 30 )) && [[ -n "$MEMORY_POOL" ]]; then
        return
    fi
    local _dir="${BASHAGT_PROJECT_DIR:-.}/.bashagt/mem_net/engrams"

    # Sleep check + dispatch only when there are existing memories/inbox messages.
    # Skip on first init — no memories, no messages, guaranteed no-op.
    if [[ -d "$_dir" ]]; then
        local _has_content=0
        for _f in "$_dir"/*/summaries.jsonl; do
            [[ -s "$_f" ]] && { _has_content=1; break; }
        done 2>/dev/null
        if (( _has_content )); then
            _mem_check_sleep_trigger
            _mem_dispatch_all
        fi
    fi
    local _pool=""
    if [[ -d "$_dir" ]]; then
        # Normalize both full-key and abbreviated-key formats to canonical abbreviated form
        # Full: {id,summary,keywords,category,importance,source,ts,timestamp}
        # Abbreviated: {id,s,k,c,i,src,t,n}
        _pool=$(cat "$_dir"/*/summaries.jsonl 2>/dev/null | jq -s '
            [.[] | {
                id: .id,
                s: (.s // .summary // "?"),
                k: (.k // .keywords // []),
                c: (.c // .category // "general"),
                i: (.i // .importance // 5),
                src: (.src // .source // "manual"),
                t: (.t // .ts // .timestamp // 0),
                n: (.n // 1)
            }]
        ' 2>/dev/null || echo "[]")
    fi
    MEMORY_POOL="$_pool"
    MEMORY_CACHE_TS=$_now
    local _mem_count; _mem_count=$(jq 'length' <<< "$_pool" 2>/dev/null || echo 0)
    log "DEBUG: [MEM] refresh_cache: memories=$_mem_count age=$((_now - MEMORY_CACHE_TS))s"
}

# ── Recall: passive (no LLM, every turn) ──
load_memories() {
    MEM_SLOT_TABLE="${BASHAGT_PROJECT_DIR:-.}/.bashagt/mem_net/slot_table.json"
    mkdir -p "${BASHAGT_PROJECT_DIR:-.}/.bashagt/mem_net/engrams"
    if [[ ! -f "$MEM_SLOT_TABLE" ]]; then
        _mem_init_slot_table "$MEM_SLOT_TABLE"
    fi

    log "DEBUG: [MEM] load_memories: engrams=$MEM_ENGRAM_COUNT slots=$MEM_ENGRAM_SLOTS capacity=$MEM_TOTAL_CAPACITY"

    # Lazy: engram agent registration and cache refresh deferred
    # to first build_memory_context() call (masked by API latency).
    MEMORY_POOL=""
    _MEM_LAZY_LOADED=0
}

build_memory_context() {
    [[ "${BASHAGT_MEMORY_ENABLED:-$DEFAULT_MEMORY_ENABLED}" != "true" ]] && { echo ""; return; }
    if (( _MEM_LAZY_LOADED == 0 )); then
        _register_engram_agents  # deferred from load_memories
        _MEM_LAZY_LOADED=1
    fi
    _mem_refresh_cache
    [[ -z "$MEMORY_POOL" || "$MEMORY_POOL" == "[]" ]] && { echo ""; return; }

    local max_lines="${BASHAGT_MEMORY_MAX_CONTEXT:-$DEFAULT_MEMORY_MAX_CONTEXT}"

    # Extract last user message for keyword matching (2+ char alphanumeric tokens)
    local _user_query=""
    _user_query=$(jq -r '[.[] | select(.role=="user")] | last | [.content[]? | select(.type=="text") | .text] | join(" ")' <<< "$MESSAGES" 2>/dev/null)
    [[ "$_user_query" == "null" ]] && _user_query=""

    # Hybrid: hot (access×importance) + keyword match from last user message
    local _mem_r combined total _selected
    if [[ -n "$_user_query" ]]; then
        _mem_r=$(printf '%s' "$MEMORY_POOL" | jq --argjson max "$max_lines" --arg q "$_user_query" '
          ($q | [scan("[\\p{L}\\p{N}_]{2,}")] | unique) as $qwords
          | {
            total:  (length | tostring),
            lines:  ([.[] | select(.i > 0.1)
                     | .kws = (.k // [])
                     | .kw_match = (
                         reduce $qwords[] as $w (0;
                           . + (if ([.kws[]? | test($w; "i")] | any) then 1 else 0 end)
                         )
                       )
                     | .score = (.n * .i + (now - (.t // 0)) / 86400 * 0.1 + .kw_match * 2.0)
                     ]
                    | sort_by(-.score)
                    | .[0:$max]
                    | .[] | "- [`\(.id)`] \(.s) [\(.c // "")]")
          }
        ' 2>/dev/null)
    else
        _mem_r=$(printf '%s' "$MEMORY_POOL" | jq --argjson max "$max_lines" '
          {
            total:  (length | tostring),
            lines:  ([.[] | select(.i > 0.1)]
                    | sort_by(-(.n * .i + (now - (.t // 0)) / 86400 * 0.1))
                    | .[0:$max]
                    | .[] | "- [`\(.id)`] \(.s) [\(.c // "")]")
          }
        ' 2>/dev/null)
    fi
    combined=$(jq -r '.lines // ""' <<< "$_mem_r" 2>/dev/null)
    total=$(jq -r '.total // 0' <<< "$_mem_r" 2>/dev/null)

    if [[ -n "$combined" ]] && [[ "$combined" != "null" ]]; then
        _selected=$(printf '%s\n' "$combined" | wc -l)
        log "DEBUG: [MEM] build_context: selected=$_selected total=$total max=$max_lines"
        printf '## Memory Network (%d total)\n' "$total"
        printf '  Full content: .bashagt/mem_net/engrams/{engram}/mem/{slot}.md\n'
        printf '%s\n' "$combined"
    fi
}

# ── Sleep Phase helpers ──

# Write a group of memories to a range of engrams via send_message + dispatch
# Creates both summaries.jsonl AND mem/*.md files via engram agent sleep_rebuild action
_mem_sleep_write_group() {
    local data="$1" start_engram="$2" end_engram="$3"
    local net_dir="${BASHAGT_PROJECT_DIR:-.}/.bashagt/mem_net/engrams"
    local total; total=$(jq 'length' <<< "$data" 2>/dev/null || echo 0)
    [[ $total -eq 0 ]] && return
    local per_engram=$(( (total + (end_engram - start_engram)) / (end_engram - start_engram + 1) ))
    [[ $per_engram -lt 1 ]] && per_engram=1
    local _ei=0 _engram_idx=$start_engram _pids=()
    while (( _engram_idx <= end_engram && _engram_idx < MEM_ENGRAM_COUNT )); do
        local _eid; _eid=$(printf 'engram_%02d' "$_engram_idx")
        local _edir="$net_dir/$_eid"; mkdir -p "$_edir/mem"
        local _chunk; _chunk=$(jq --argjson ei "$_ei" --argjson ps "$per_engram" '.[$ei:($ei+$ps)]' <<< "$data")
        # Re-index IDs and create mem files
        local _new_summaries="" _li=1 _row _content
        while IFS= read -r _row; do
            [[ -z "$_row" ]] && continue
            local _nid; _nid=$(printf '%04d' "$_li")
            _row=$(jq --arg nid "$_eid/$_nid" '.id = $nid' <<< "$_row")
            # Write mem/*.md file with content (always, even if content empty)
            _content=$(jq -r '.content // ""' <<< "$_row" 2>/dev/null)
            local _kw; _kw=$(jq -r '.k // [] | join(", ")' <<< "$_row" 2>/dev/null)
            cat > "$_edir/mem/${_nid}.md" << MDEOF
---
id: $_nid
summary: $(jq -r '.s // ""' <<< "$_row")
keywords: [$_kw]
category: $(jq -r '.c // "general"' <<< "$_row")
created: $(jq -r '.t // 0' <<< "$_row")
access_count: $(jq -r '.n // 0' <<< "$_row")
importance: $(jq -r '.i // 0.5' <<< "$_row")
source: $(jq -r '.src // "manual"' <<< "$_row")
---

$_content
MDEOF
            # Strip content from summary line (not stored in summaries.jsonl)
            _row=$(jq 'del(.content)' <<< "$_row" 2>/dev/null)
            [[ -n "$_new_summaries" ]] && _new_summaries+=$'\n'
            _new_summaries+="$_row"
            _li=$((_li + 1))
        done < <(jq -c '.[]' <<< "$_chunk" 2>/dev/null)
        if [[ -n "$_new_summaries" ]]; then
            printf '%s\n' "$_new_summaries" > "$_edir/summaries.jsonl"
        else
            rm -f "$_edir/summaries.jsonl"
        fi
        _ei=$((_ei + per_engram))
        _engram_idx=$((_engram_idx + 1))
    done
}

# Check and potentially trigger sleep phase (two conditions)
_mem_check_sleep_trigger() {
    local table="${MEM_SLOT_TABLE:-.bashagt/mem_net/slot_table.json}"
    local _cur_rate; _cur_rate=$(_mem_write_rate)
    local _in_sleep; _in_sleep=$(jq -r '.global.sleep_phase // false' "$table" 2>/dev/null)
    [[ "$_in_sleep" == "true" ]] && return

    log "DEBUG: [MEM] sleep_check: write_rate=$_cur_rate threshold=0.85 in_sleep=$_in_sleep"
    if awk "BEGIN { exit ($_cur_rate >= 0.85 ? 0 : 1) }" 2>/dev/null; then
        log "MEM: write_rate=%.2f — triggering sleep phase" "$_cur_rate"
        _mem_enter_sleep_phase
    fi
}

# Retain top 90% by importance × validation_score
_mem_sleep_retain() {
    local data="$1"
    local _nt; _nt=$(jq 'length' <<< "$data" 2>/dev/null || echo 0)
    local _keep=$(( _nt * 9 / 10 ))
    [[ $_keep -lt 1 ]] && _keep=1
    jq --argjson keep "$_keep" '
        # obsolete: push to end (penalty), keep: sort by i×0.6 + validation_score×0.4
        map(. + {_score: (if .verdict == "obsolete" then -999
                          else (.i // 0.5) * 0.6 + (.validation_score // 0.5) * 0.4 end)})
        | sort_by(-._score)
        | .[0:$keep]
        | del(.[]._score)
    ' <<< "$data" 2>/dev/null || echo "$data"
}

# ── Sleep Phase: full memory network reconstruction ──
_mem_enter_sleep_phase() {
    local table="${MEM_SLOT_TABLE:-.bashagt/mem_net/slot_table.json}"
    local net_dir="${BASHAGT_PROJECT_DIR:-.}/.bashagt/mem_net/engrams"

    log "MEM: === Sleep Phase START ==="
    local _start_ts; _start_ts=${EPOCHSECONDS:-$(date +%s)}

    # Set global lock
    jq '.global.sleep_phase = true' "$table" > "$table.tmp" 2>/dev/null && mv "$table.tmp" "$table"

    # Phase 0: Collect all summaries + content (read mem/*.md for content)
    log "MEM: Phase 0 — collecting all memories..."
    local _all="[]" _row _mem_file _content _engram_dir
    for _engram_dir in "$net_dir"/engram_*/; do
        local _eid; _eid=$(basename "$_engram_dir")
        [[ ! -f "$_engram_dir/summaries.jsonl" ]] && continue
        while IFS= read -r _row; do
            [[ -z "$_row" ]] && continue
            local _mid; _mid=$(jq -r '.id' <<< "$_row" 2>/dev/null)
            local _slot; _slot="${_mid##*/}"
            _mem_file="$_engram_dir/mem/${_slot}.md"
            if [[ -f "$_mem_file" ]]; then
                _content=$(sed '1,/^---$/d' "$_mem_file" 2>/dev/null | sed '/./,$!d' || echo "")
                _row=$(jq --arg c "$_content" '. + {content: $c}' <<< "$_row" 2>/dev/null)
            fi
            _all=$(jq --argjson row "$_row" '. + [$row]' <<< "$_all" 2>/dev/null)
        done < "$_engram_dir/summaries.jsonl"
    done
    local _total; _total=$(jq 'length' <<< "$_all" 2>/dev/null || echo 0)
    log "MEM: Phase 0 — collected $_total memories with content"

    # ── Phase 1: Regroup by src type ──
    log "MEM: Phase 1 — regrouping by type..."
    local _manual _compress _auto
    _manual=$(jq '[.[] | select(.src == "manual")] | sort_by(.t)' <<< "$_all")
    _compress=$(jq '[.[] | select(.src == "compress")] | sort_by(.t)' <<< "$_all")
    _auto=$(jq '[.[] | select(.src == "auto")] | sort_by(.t)' <<< "$_all")

    local _nm _nc _na
    _nm=$(jq 'length' <<< "$_manual" 2>/dev/null || echo 0)
    _nc=$(jq 'length' <<< "$_compress" 2>/dev/null || echo 0)
    _na=$(jq 'length' <<< "$_auto" 2>/dev/null || echo 0)
    log "MEM: manual=$_nm compress=$_nc auto=$_na"

    local _engram_manual_end=$(( (_nm + MEM_ENGRAM_SLOTS - 1) / MEM_ENGRAM_SLOTS ))
    local _engram_compress_end=$(( _engram_manual_end + (_nc + MEM_ENGRAM_SLOTS - 1) / MEM_ENGRAM_SLOTS ))
    [[ $_engram_manual_end -lt 1 ]] && _engram_manual_end=1
    [[ $_engram_compress_end -le $_engram_manual_end ]] && _engram_compress_end=$(( _engram_manual_end + 1 ))

    # Clear unused engrams (writes deferred to Phase 6 after validation+compression)
    local _last_used=$(( (_na > 0) ? MEM_ENGRAM_COUNT - 1 : (_nc > 0 ? _engram_compress_end - 1 : _engram_manual_end - 1) ))
    local _ei
    for (( _ei=_last_used+1; _ei<MEM_ENGRAM_COUNT; _ei++ )); do
        local _cl_eid; _cl_eid=$(printf 'engram_%02d' "$_ei")
        rm -f "$net_dir/$_cl_eid/summaries.jsonl" "$net_dir/$_cl_eid/mem/"*.md 2>/dev/null || true
    done
    log "DEBUG: [MEM] sleep_phase1: manual=$_nm compress=$_nc auto=$_na"
    log "MEM: Phase 1 done"

    # ── Phase 2: Time-sort (already from Phase 1 jq sort_by(.t)) ──
    log "MEM: Phase 2 — time-sorting (done in regroup)"

    # ── Phase 3: Cross-validate compress → manual+auto (PARALLEL per engram) ──
    log "MEM: Phase 3 — cross-validating (parallel per engram)..."
    local _validated_manual="$_manual" _validated_auto="$_auto"

    if [[ -n "${AGENTS[mem_validator]:-}" ]] && [[ "$_nc" -gt 0 ]]; then
        local _baseline; _baseline=$(jq -r '[.[] | .s] | join("\n")' <<< "$_compress" 2>/dev/null | dd bs=12000 count=1 2>/dev/null)
        local _tmpd="${BASHAGT_TMPDIR:-/tmp}/mem_valid_$$"; mkdir -p "$_tmpd"

        # Parallel validate: fork one call per populated manual engram
        if (( _nm > 0 )); then
            local _vei _veid _vpids=()
            for (( _vei=0; _vei<_engram_manual_end && _vei<MEM_ENGRAM_COUNT; _vei++ )); do
                _veid=$(printf 'engram_%02d' "$_vei")
                local _vf="$_tmpd/${_veid}.json"
                local _vdata; _vdata=$(jq --argjson ei "$_vei" --argjson ps $MEM_ENGRAM_SLOTS '
                    .[$ei*$ps:($ei+1)*$ps]' <<< "$_manual" 2>/dev/null)
                local _vtext; _vtext=$(jq -r '.[] | "[`\(.id)`] \(.s)"' <<< "$_vdata" 2>/dev/null)
                [[ -z "$_vtext" ]] && continue
                (
                    local _vp; _vp="Baseline:\n$_baseline\n\nMemories to validate:\n$_vtext\n\nFor each, return: [{id, verdict:\"keep\"|\"obsolete\", validation_score:0.0-1.0, reason:\"short\"}]. Output ONLY JSON."
                    call_agent "mem_validator" "$_vp" > "$_vf" 2>/dev/null || echo "[]" > "$_vf"
                ) &
                _vpids+=($!)
            done
            wait "${_vpids[@]}" 2>/dev/null || true
            # Collect validation results
            local _vall; _vall=$(cat "$_tmpd"/engram_*.json 2>/dev/null | jq -s 'add' 2>/dev/null || echo "[]")
            _validated_manual=$(jq --argjson vm "$_vall" '
                [.[] as $m | ($vm[] | select(.id == $m.id) // {verdict:"keep",validation_score:0.5}) as $v |
                $m + {verdict:$v.verdict, validation_score:$v.validation_score}] |
                sort_by(.t)
            ' <<< "$_manual" 2>/dev/null || echo "$_manual")
        fi

        # Parallel validate: auto engrams
        if (( _na > 0 )); then
            local _vei _veid _vpids=()
            for (( _vei=_engram_compress_end; _vei<MEM_ENGRAM_COUNT; _vei++ )); do
                _veid=$(printf 'engram_%02d' "$_vei")
                local _vf="$_tmpd/${_veid}.json"
                local _veidx=$((_vei - _engram_compress_end))
                local _vdata; _vdata=$(jq --argjson ei "$_veidx" --argjson ps $MEM_ENGRAM_SLOTS '
                    .[$ei*$ps:($ei+1)*$ps]' <<< "$_auto" 2>/dev/null)
                local _vtext; _vtext=$(jq -r '.[] | "[`\(.id)`] \(.s)"' <<< "$_vdata" 2>/dev/null)
                [[ -z "$_vtext" ]] && continue
                (
                    local _vp; _vp="Baseline:\n$_baseline\n\nMemories to validate:\n$_vtext\n\nFor each, return: [{id, verdict:\"keep\"|\"obsolete\", validation_score:0.0-1.0, reason:\"short\"}]. Output ONLY JSON."
                    call_agent "mem_validator" "$_vp" > "$_vf" 2>/dev/null || echo "[]" > "$_vf"
                ) &
                _vpids+=($!)
            done
            wait "${_vpids[@]}" 2>/dev/null || true
            local _vall; _vall=$(cat "$_tmpd"/engram_*.json 2>/dev/null | jq -s 'add' 2>/dev/null || echo "[]")
            # Only apply to auto memories
            _validated_auto=$(jq --argjson vm "$_vall" '
                [.[] as $m | ($vm[] | select(.id == $m.id) // {verdict:"keep",validation_score:0.5}) as $v |
                $m + {verdict:$v.verdict, validation_score:$v.validation_score}] |
                sort_by(.t)
            ' <<< "$_auto" 2>/dev/null || echo "$_auto")
        fi
        rm -rf "$_tmpd" 2>/dev/null || true
    fi
    log "MEM: Phase 3 done"

    # ── Phase 4: Retain 90% manual, 90% auto (using i×0.6 + validation×0.4) ──
    log "MEM: Phase 4 — retention..."
    _validated_manual=$(_mem_sleep_retain "$_validated_manual")
    _validated_auto=$(_mem_sleep_retain "$_validated_auto")
    log "MEM: Phase 4 done — manual=$(jq 'length' <<< "$_validated_manual"), auto=$(jq 'length' <<< "$_validated_auto")"

    # ── Phase 5: Time-weighted compress (ALL compress memories, parallel) ──
    log "MEM: Phase 5 — time-weighted compression..."
    if [[ -n "${AGENTS[mem_compressor]:-}" ]] && [[ "$_nc" -gt 0 ]]; then
        local _now; _now=${EPOCHSECONDS:-$(date +%s)}
        local _tmpc="${BASHAGT_TMPDIR:-/tmp}/mem_comp_$$"; mkdir -p "$_tmpc"

        # Calculate target_bytes for each compress memory
        local _compress_with_targets
        _compress_with_targets=$(jq --argjson now "$_now" '
            [.[] | . as $m |
             (($now - .t) / 86400) as $age |
             (if $age <= 7 then 0.95
              elif $age <= 30 then 0.85
              elif $age <= 90 then 0.70
              else 0.50 end) as $retention |
             $m + {target_bytes: ((.sz // ($m.s | length)) * $retention | floor)}]
        ' <<< "$_compress" 2>/dev/null || echo "$_compress")

        # Parallel compress: fork per compress engram
        local _cei _ceid _cpids=()
        for (( _cei=_engram_manual_end; _cei<_engram_compress_end && _cei<MEM_ENGRAM_COUNT; _cei++ )); do
            _ceid=$(printf 'engram_%02d' "$_cei")
            local _cf="$_tmpc/${_ceid}.json"
            local _ceidx=$((_cei - _engram_manual_end))
            local _cdata; _cdata=$(jq --argjson ei "$_ceidx" --argjson ps $MEM_ENGRAM_SLOTS '
                .[$ei*$ps:($ei+1)*$ps] |
                map({id, s, target_bytes})
            ' <<< "$_compress_with_targets" 2>/dev/null)
            local _ccount; _ccount=$(jq 'length' <<< "$_cdata" 2>/dev/null || echo 0)
            [[ $_ccount -eq 0 ]] && continue
            (
                local _cp; _cp="Compress each memory to its target_bytes. Input:\n$(jq -c '.' <<< "$_cdata" 2>/dev/null)\n\nReturn ONLY JSON: [{id, compressed_summary}]"
                call_agent "mem_compressor" "$_cp" > "$_cf" 2>/dev/null || echo "[]" > "$_cf"
            ) &
            _cpids+=($!)
        done
        wait "${_cpids[@]}" 2>/dev/null || true

        # Apply compressed summaries
        local _call; _call=$(cat "$_tmpc"/engram_*.json 2>/dev/null | jq -s 'add' 2>/dev/null || echo "[]")
        while IFS= read -r _crow; do
            [[ -z "$_crow" ]] && continue
            local _cid; _cid=$(jq -r '.id // ""' <<< "$_crow" 2>/dev/null)
            local _ctxt; _ctxt=$(jq -r '.compressed_summary // ""' <<< "$_crow" 2>/dev/null)
            [[ -z "$_cid" || -z "$_ctxt" ]] && continue
            _compress=$(jq --arg cid "$_cid" --arg ctxt "$_ctxt" '
                map(if .id == $cid then .s = $ctxt | .sz = ($ctxt | length) else . end)
            ' <<< "$_compress" 2>/dev/null || echo "$_compress")
        done < <(printf '%s' "$_call" | jq -c '.[]' 2>/dev/null)
        rm -rf "$_tmpc" 2>/dev/null || true
    fi
    log "MEM: Phase 5 done"

    # ── Phase 6: Rebuild slot_table ──
    log "MEM: Phase 6 — rebuilding slot table..."
    _mem_sleep_write_group "$_validated_manual" 0 $((_engram_manual_end - 1))
    _mem_sleep_write_group "$_compress" "$_engram_manual_end" $((_engram_compress_end - 1))
    _mem_sleep_write_group "$_validated_auto" "$_engram_compress_end" $((MEM_ENGRAM_COUNT - 1))

    local _eid _total_used=0 _used
    for (( _ei=0; _ei<MEM_ENGRAM_COUNT; _ei++ )); do
        _eid=$(printf 'engram_%02d' "$_ei")
        _used=$(wc -l < "$net_dir/$_eid/summaries.jsonl" 2>/dev/null || echo 0)
        _total_used=$((_total_used + _used))
        jq --arg eid "$_eid" --argjson used "$_used" '
            .engrams[$eid].used = $used |
            .engrams[$eid].free = .engrams[$eid].total - $used |
            .engrams[$eid].last_write = (now | floor)
        ' "$table" > "$table.tmp" 2>/dev/null && mv "$table.tmp" "$table"
    done

    jq --argjson tu "$_total_used" '
        .global.total_used = $tu |
        .global.total_free = .global.total_capacity - $tu |
        .global.write_rate = ($tu / .global.total_capacity) |
        .global.sleep_phase = false |
        .global.last_sleep_phase = (now | floor)
    ' "$table" > "$table.tmp" 2>/dev/null && mv "$table.tmp" "$table"

    _mem_refresh_cache
    local _elapsed; _elapsed=$((${EPOCHSECONDS:-$(date +%s)} - _start_ts))
    local _new_rate; _new_rate=$(jq -r '.global.write_rate' "$table")
    log "DEBUG: [MEM] sleep_phase6: rebuilt engrams=$MEM_ENGRAM_COUNT total=$_total_used elapsed=${_elapsed}s"
    log "MEM: === Sleep Phase DONE (${_elapsed}s) — write_rate: $(awk "BEGIN { printf \"%.1f\", $_new_rate * 100 }")% ==="
}

# ============================================================================
# SECTION 7d: TODO System — persistent task tracking with Plan mode integration
# ============================================================================

TODOS='[]'
# _set_project_paths() in main() sets the authoritative value.
TODO_FILE=""
_LAST_TODO_ID=""

# Load TODO list from disk
load_todos() {
    if [[ -f "$TODO_FILE" ]]; then
        TODOS=$(jq '.' "$TODO_FILE" 2>/dev/null) || true
        if [[ -z "$TODOS" ]] || [[ "$TODOS" == "null" ]]; then
            log "WARN: Corrupt TODO file, backing up and resetting"
            mv "$TODO_FILE" "${TODO_FILE}.bak" 2>/dev/null || true
            TODOS='[]'
        fi
    else
        TODOS='[]'
    fi
}

# Write TODO list to disk (atomic: write to tmp then rename)
save_todos() {
    mkdir -p "$(dirname "$TODO_FILE")" 2>/dev/null || true
    local _tmp; _tmp=$(_mktemp_file "${TODO_FILE}.tmp.XXXXXX" 2>/dev/null)
    if [[ -z "$_tmp" ]] || [[ ! -f "$_tmp" ]]; then
        log "WARN: mktemp failed, falling back to direct write"
        if ! printf '%s' "$TODOS" > "$TODO_FILE"; then
            log "WARN: Failed to save TODO list to $TODO_FILE"
            return 1
        fi
        return 0
    fi
    if ! printf '%s' "$TODOS" > "$_tmp"; then
        log "WARN: Failed to save TODO list"
        rm -f "$_tmp"
        return 1
    fi
    # Trace: record TODO changes
    if [[ "${BASHAGT_TRACE_ENABLED:-1}" == "1" ]]; then
        local _old_todos; _old_todos=$(cat "$TODO_FILE" 2>/dev/null || echo "[]")
        trace_record "$TODO_FILE" "$_old_todos" "$TODOS" \
            "$(jq -nc --arg agent "${AGENT_SELF_NAME:-system}" --arg tool "save_todos" \
               --arg desc "update todo list" --argjson turn 0 \
               '{agent:$agent, tool:$tool, turn:$turn, desc:$desc}')" 2>/dev/null || true
    fi
    mv "$_tmp" "$TODO_FILE" 2>/dev/null || {
        log "WARN: Failed to rename TODO tmp file"
        rm -f "$_tmp"
        return 1
    }
}

# ── _count_active_todos ──
# Returns count of non-completed TODOs. Used by pre_turn hook context.
_ACTIVE_TODOS_CACHED=""
_ACTIVE_TODOS_TS=0
_count_active_todos() {
    local _now=${EPOCHSECONDS:-$(date +%s)}
    if [[ -n "${_ACTIVE_TODOS_CACHED:-}" ]] && (( _now - _ACTIVE_TODOS_TS < 30 )); then
        printf '%s' "$_ACTIVE_TODOS_CACHED"
        return
    fi
    local _count=0
    if [[ -n "${TODOS:-}" && "$TODOS" != "null" ]]; then
        _count=$(jq '[.[] | select(.status != "completed")] | length' <<< "$TODOS" 2>/dev/null || echo 0)
    fi
    _ACTIVE_TODOS_CACHED="$_count"
    _ACTIVE_TODOS_TS=$_now
    printf '%s' "$_count"
}

# Add a TODO item. Returns the new item's ID on stdout.
# Usage: todo_add "subject" ["description"] ["source"]
todo_add() {
    local subject="$1" description="${2:-}" source="${3:-manual}"
    local id date _desc_json
    id="todo_$(_timestamp_ms)${RANDOM}"
    date=$(date +%Y-%m-%d)
    _desc_json=$(printf '%s' "$description" | jq -Rs '.')
    TODOS=$(echo "$TODOS" | jq \
        --arg id "$id" --arg subject "$subject" \
        --argjson desc "$_desc_json" \
        --arg status "pending" --arg source "$source" --arg date "$date" \
        '. + [{"id":$id,"subject":$subject,"description":$desc,"status":$status,"source":$source,"created":$date,"completed":""}]')
    if ! save_todos; then
        log "WARN: Failed to save TODO list"
        return 1
    fi
    _LAST_TODO_ID="$id"
    printf '%s' "$id"
}

# Resolve a full task ID from either exact or prefix match.
# The model sees abbreviated IDs from task_list (chars 5-18), so prefix
# matching is essential for task_update/task_delete to work correctly.
# Usage: _resolve_todo_id "$id" → full_id or empty
_resolve_todo_id() {
    local _rid="$1" _match
    # Exact match first
    _match=$(echo "$TODOS" | jq -r --arg id "$_rid" '.[] | select(.id == $id) | .id' 2>/dev/null)
    if [[ -n "$_match" ]]; then
        printf '%s' "$_match"; return 0
    fi
    # Substring fallback for abbreviated IDs (model sees _id:5:13 from task_list)
    local _count; _count=$(echo "$TODOS" | jq --arg id "$_rid" '[.[] | select(.id | contains($id))] | length')
    if [[ "$_count" == "1" ]]; then
        _match=$(echo "$TODOS" | jq -r --arg id "$_rid" '.[] | select(.id | contains($id)) | .id')
        printf '%s' "$_match"; return 0
    elif [[ "$_count" -gt 1 ]]; then
        # Ambiguous: prefer non-completed tasks (in_progress > pending > first created)
        _match=$(echo "$TODOS" | jq -r --arg id "$_rid" '
            [.[] | select(.id | contains($id))]
            | sort_by(if .status == "in_progress" then 0
                      elif .status == "pending" then 1
                      elif .status == "failed" then 2
                      else 3 end, .created)
            | .[0].id // empty' 2>/dev/null)
        if [[ -n "$_match" ]]; then
            printf '%s' "$_match"; return 0
        fi
    fi
    return 1
}

# Update a TODO item. Fields provided are merged; omitted fields left unchanged.
# Usage: todo_update <id> ["status"] ["subject"] ["description"]
todo_update() {
    local id="$1" status="${2:-}" subject="${3:-}" description="${4:-}"
    local exists _date _desc_json _resolved_id
    _resolved_id=$(_resolve_todo_id "$id") || true
    if [[ -z "$_resolved_id" ]]; then
        log "WARN: Task '$id' not found in TODOS"
        return 1
    fi
    id="$_resolved_id"
    _date=$(date +%Y-%m-%d)
    _desc_json="null"
    [[ -n "$description" ]] && _desc_json=$(printf '%s' "$description" | jq -Rs '.')
    TODOS=$(echo "$TODOS" | jq \
        --arg id "$id" \
        --arg status "$status" --arg subject "$subject" \
        --argjson desc "$_desc_json" --arg date "$_date" \
        'map(if .id == $id then
            (if $status != "" then .status = $status else . end) |
            (if $subject != "" then .subject = $subject else . end) |
            (if $desc != null then .description = $desc else . end) |
            (if $status == "completed" or $status == "failed" then .completed = $date elif $status != "" then .completed = "" else . end)
         else . end)')
    save_todos

}

# Delete a TODO item by ID
todo_delete() {
    local id="$1" before after _resolved_id
    _resolved_id=$(_resolve_todo_id "$id") || true
    if [[ -z "$_resolved_id" ]]; then
        _stream_emit "warning" "$(jq -nc --arg c "Task \"$id\" not found." '{content: $c}')"
        return 1
    fi
    id="$_resolved_id"
    before=$(echo "$TODOS" | jq 'length')
    TODOS=$(echo "$TODOS" | jq --arg id "$id" '[.[] | select(.id != $id)]')
    after=$(echo "$TODOS" | jq 'length')
    if (( before == after )); then
        _stream_emit "warning" "$(jq -nc --arg c "Task \"$id\" not found." '{content: $c}')"
        return 1
    fi
    save_todos
}

# Generate colored progress bar: <done> <total> [width=20]
_todo_progress_bar() { ui_progress "$@"; }

# Extract plan name from TODOs (first plan-sourced item's subject prefix)
_todo_plan_name() {
    local name
    name=$(echo "$TODOS" | jq -r '[.[] | select(.source == "plan")] | first | .subject // empty' 2>/dev/null)
    printf '%s' "$name"
}

# Render one status section (▶/○/✓) with tree drawing
# Usage: _todo_render_section <status> <label> <color_var_name> <max>
_todo_render_section() {
    local _status="$1" _label="$2" _color="$3" _max="${4:-999}"
    local _items _count _idx=0 _branch _symbol _display
    _items=$(echo "$TODOS" | jq -r --arg s "$_status" \
        '.[] | select(.status == $s) | "\(.subject | gsub("\t";" "))\t\(.source)\t\(.id)"')
    _count=$(printf '%s' "$_items" | grep -c . 2>/dev/null || echo 0)
    if (( _count == 0 )); then
        return
    fi
    local _sec_out=''
    _sec_out+=$(printf '%s%s%s (%d):' "$_color" "$_label" "$RESET" "$_count")$'\n'
    local _shown=0 _line
    while IFS=$'\t' read -r _subject _source _id; do
        [[ -z "$_subject" ]] && continue
        _shown=$((_shown + 1))
        if (( _shown > _max )); then
            local _remaining=$(( _count - _max ))
            _sec_out+=$(printf '    %s├%s %s... and %d more%s' \
                "$DIM" "$RESET" "$DIM" "$_remaining" "$RESET")$'\n'
            break
        fi
        if (( _shown == _count )); then
            _branch="└"
        else
            _branch="├"
        fi
        _sec_out+=$(printf '    %s%s%s %-45s  %s%s · %s%s' \
            "$_color" "$_branch" "$RESET" "$_subject" \
            "$DIM" "$_source" "${_id:5:8}" "$RESET")$'\n'
    done <<< "$_items"
    printf '%s' "$_sec_out"
}

# Display full TODO list with progress bar and tree sections
todo_list() {
    local total pending in_progress completed
    total=$(echo "$TODOS" | jq 'length')
    local _td_out=''
    _td_out+=$(printf '  %s═══ TODO ═══%s' "$BOLD" "$RESET")
    local _plan; _plan=$(_todo_plan_name)
    if [[ -n "$_plan" ]]; then
        _td_out+=$(printf '  Plan: %s"%s"%s' "$DIM" "$_plan" "$RESET")
    fi
    _td_out+=$'\n'
    if (( total == 0 )); then
        _td_out+=$(printf '  No tasks yet. Create one with %s/todo-add%s or %s/plan%s.\n' \
            "$DIM" "$RESET" "$DIM" "$RESET")
        _stream_emit "text" "$(jq -nc --arg c "$_td_out" '{content: $c}')"
        return
    fi
    pending=$(echo "$TODOS" | jq '[.[] | select(.status == "pending")] | length')
    in_progress=$(echo "$TODOS" | jq '[.[] | select(.status == "in_progress")] | length')
    completed=$(echo "$TODOS" | jq '[.[] | select(.status == "completed")] | length')
    _td_out+=$(_todo_progress_bar "$completed" "$total")$'\n'
    _td_out+=$(_todo_render_section "in_progress" "▶ In Progress" "$CYAN")
    _td_out+=$(_todo_render_section "pending" "○ Pending" "$YELLOW" 10)
    _td_out+=$(_todo_render_section "completed" "✓ Done" "$GREEN" 5)
    _td_out+=$(_todo_render_section "failed" "✗ Failed" "$RED" 5)
    _td_out+=$'\n'
    _stream_emit "text" "$(jq -nc --arg c "$_td_out" '{content: $c}')"
}

# Resolve 1-based display index to actual TODO ID
todo_find_by_index() {
    local idx="$1" jq_idx
    jq_idx=$(( idx - 1 ))
    # Guard: index 0 (or negative) would resolve to wrong element via jq .[-1]
    (( jq_idx < 0 )) && return 1
    echo "$TODOS" | jq -r --argjson i "$jq_idx" '.[$i].id // empty'
}

# Build concise TODO context for injection into system prompt
build_todo_context() {
    [[ "${BASHAGT_TODO_ENABLED:-$DEFAULT_TODO_ENABLED}" != "true" ]] && { echo ""; return; }
    local count; count=$(echo "$TODOS" | jq 'length')
    if (( count == 0 )); then
        echo ""
        return
    fi
    local max_items="${BASHAGT_TODO_MAX_CONTEXT:-$DEFAULT_TODO_MAX_CONTEXT}"
    local in_prog current_text pending_text done_count plan_name
    in_prog=$(echo "$TODOS" | jq -r '.[] | select(.status == "in_progress") | "\(.id)\t\(.subject | gsub("\t";" "))"')
    done_count=$(echo "$TODOS" | jq '[.[] | select(.status == "completed")] | length')
    plan_name=$(_todo_plan_name)
    local ctx="## Active Tasks — implement ONE at a time, in order"
    if [[ -n "$plan_name" ]]; then
        ctx+=$'\n'"Plan: \"$plan_name\"  ($done_count done / $count total)"
        # Reference plan file if it exists
        local _plan_file="${BASHAGT_PROJECT_DIR:-$PWD}/.bashagt/plan.md"
        if [[ -f "$_plan_file" ]]; then
            ctx+=$'\n'"  Plan file: .bashagt/plan.md (read with read_file(\".bashagt/plan.md\"))"
        fi
    fi
    ctx+=$'\n'
    if [[ -n "$in_prog" ]]; then
        local _cur_id _cur_subj
        _cur_id=$(printf '%s' "$in_prog" | cut -f1)
        _cur_subj=$(printf '%s' "$in_prog" | cut -f2)
        ctx+=$'\n'"▶ CURRENT (do this now): [$_cur_id] $_cur_subj"
    fi
    local pending_list
    pending_list=$(echo "$TODOS" | jq -r --argjson max "$max_items" \
        '[.[] | select(.status == "pending")] | sort_by(.created) | .[0:$max] | .[] | "\(.id)\t\(.subject | gsub("\t";" "))"')
    if [[ -n "$pending_list" ]]; then
        if [[ -z "$in_prog" ]]; then
            local _first_id _first_subj
            _first_id=$(printf '%s' "$pending_list" | head -1 | cut -f1)
            _first_subj=$(printf '%s' "$pending_list" | head -1 | cut -f2)
            ctx+=$'\n'"▶ CURRENT (first pending): [$_first_id] $_first_subj"
            pending_list=$(printf '%s' "$pending_list" | tail -n +2 | cut -f2)
        fi
        if [[ -n "$pending_list" ]]; then
            ctx+=$'\n'"UP NEXT:"
            local _idx=1 _line
            while IFS= read -r _line; do
                [[ -z "$_line" ]] && continue
                local _pn_id="" _pn_subj="$_line"
                if [[ "$_line" == *$'\t'* ]]; then
                    _pn_id="${_line%%$'\t'*}"
                    _pn_subj="${_line#*$'\t'}"
                    _pn_id="[${_pn_id: -8}] "  # last 8 chars for uniqueness
                fi
                ctx+=$'\n'"  $_idx. $_pn_id$_pn_subj"
                _idx=$((_idx + 1))
            done <<< "$pending_list"
        fi
    fi
    ctx+=$'\n\n'"WORKFLOW (use the exact [id] from CURRENT above):"
    ctx+=$'\n'"  - Create: make_todos(plan_text) to extract and create tasks from a plan"
    ctx+=$'\n'"           plan_text = full plan document with STEPS section"
    ctx+=$'\n'"           ⚠ make_todos runs plan_extractor agent — costs one LLM call"
    ctx+=$'\n'"  - Start: task_update(\"ID_FROM_CURRENT\", \"in_progress\") ← MUST call BEFORE any work"
    ctx+=$'\n'"  - Done:  task_update(\"ID_FROM_CURRENT\", \"completed\")"
    ctx+=$'\n'"  - Failed: task_update(\"ID_FROM_CURRENT\", \"failed\")"
    ctx+=$'\n'"  - Only ONE in_progress at a time; complete current before next."
    ctx+=$'\n'"  - CRITICAL: If nothing is in_progress, mark the first pending task in_progress BEFORE doing anything else."
    printf '%s' "$ctx"
}

# Extract plan steps using sub-agent with formatted plan text.
# Requires plan_extractor sub-agent — no regex fallback.
# Returns JSON array of step strings via stdout.
plan_extract_steps() {
    local _plan_text _steps _output _rc _desc="${2:-}"

    # ── Step 1: Gather plan text (explicit arg → cached → messages) ──
    if [[ -n "${1:-}" ]]; then
        _plan_text="$1"
    elif [[ -n "${PLAN_LAST_FORMATTED:-}" ]]; then
        _plan_text="$PLAN_LAST_FORMATTED"
    else
        # Extract raw text from last assistant message
        _plan_text=$(printf '%s\n' "$MESSAGES" | jq -r '
            [.[] | select(.role == "assistant")] | last |
            .content |
            if type == "array" then
                [.[] | select(.type == "text") | .text] | join("\n")
            else . end' 2>/dev/null)
        if [[ -z "$_plan_text" ]] || [[ "$_plan_text" == "null" ]]; then
            return 1
        fi
    fi

    # ── Step 2: Strip ANSI escape codes (from _fmt_postprocess) ──
    # Colored text confuses the sub-agent and wastes context tokens.
    _plan_text=$(printf '%s' "$_plan_text" | sed $'s/\033\[[0-9;]*m//g')

    # ── Step 3: Truncate if needed (sub-agent context budget) ──
    local _max_chars=25000
    if (( ${#_plan_text} > _max_chars )); then
        _plan_text="${_plan_text:0:_max_chars}"$'\n…[truncated]'
    fi

    # ── Step 4: Call dedicated extraction agent ──
    # The extraction protocol lives in the plan_extractor agent's system prompt.
    # We pass only the plan text — no competing instructions.
    log "INFO: [PLAN_EXTRACT] plan_text_size=${#_plan_text} calling plan_extractor agent"
    _AGENT_DISPLAY_LABEL="make_todos"
    _output=$(call_agent "plan_extractor" "$_plan_text" "$_desc" 2>/dev/null)
    _AGENT_DISPLAY_LABEL=""
    _rc=$?
    # ── Strip ANSI from LLM output (async_spin cursor codes can leak via $()) ──
    _output=$(printf '%s' "$_output" | sed $'s/\033\[[0-9;?]*[a-zA-Z]//g')
    if [[ -z "$_output" ]] || [[ "$_rc" -ne 0 ]]; then
        # Sub-agent failed — no regex fallback
        local _output_preview="${_output:0:200}"
        log "ERROR: [PLAN_EXTRACT] LLM FAILED rc=$_rc output_empty=$([[ -z "$_output" ]] && echo yes || echo no) preview=$_output_preview"
        return 1
    fi
    local _raw_len=${#_output}
    log "INFO: [PLAN_EXTRACT] LLM OK raw_len=$_raw_len raw_preview=${_output:0:300}"

    # ── Step 5: Multi-stage output validation ──

    # Stage 1: Strip common wrappers (defensive)
    _output="${_output#\`\`\`json}"
    _output="${_output#\`\`\`}"
    _output="${_output%\`\`\`}"
    _output="${_output#json}"
    _output=$(printf '%s' "$_output" | sed '/./,$!d')  # strip leading blank lines

    # Stage 2: JSON validation (suppress stdout — jq pretty-prints to pipe)
    if ! printf '%s' "$_output" | jq -e '.' >/dev/null 2>&1; then
        log "ERROR: [PLAN_EXTRACT] validation STAGE2 failed (invalid JSON): ${_output:0:200}"
        return 1
    fi

    # Stage 3: Schema validation
    local _type _len
    _type=$(printf '%s' "$_output" | jq -r 'type' 2>/dev/null)
    if [[ "$_type" != "array" ]]; then
        log "ERROR: [PLAN_EXTRACT] validation STAGE3 failed (type=$_type not array): ${_output:0:200}"
        return 1
    fi
    _len=$(printf '%s' "$_output" | jq -r 'length' 2>/dev/null)
    if [[ "$_len" == "0" ]]; then
        log "INFO: [PLAN_EXTRACT] LLM returned empty array (valid, 0 steps)"
        return 0  # valid empty — no steps found
    fi

    # Stage 4: Element validation
    local _bad
    _bad=$(printf '%s' "$_output" | jq -r '[.[] | select(type!="string" or .=="")] | length' 2>/dev/null)
    if [[ "${_bad:-0}" != "0" ]]; then
        log "ERROR: [PLAN_EXTRACT] validation STAGE4 failed ($_bad bad elements): ${_output:0:200}"
        return 1
    fi

    # Stage 5: Sanity check — long plan with 0 steps is suspicious
    if (( ${#_plan_text} > 500 && _len == 0 )); then
        log "ERROR: [PLAN_EXTRACT] validation STAGE5 failed (long plan, 0 steps): ${_output:0:200}"
        return 1
    fi

    # ── Step 6: Return validated JSON array (plan_to_todos will parse it) ──
    log "INFO: [PLAN_EXTRACT] LLM SUCCESS: $_len steps → $(printf '%s' "$_output" | jq -c '.')"
    printf '%s' "$_output"
    return 0
}

# ── Unified Plan Mode: plan file persistence ──

# Save plan output to .bashagt/plan.md
_plan_save() {
    local _plan_output="$1" _target="${2:-$PWD}"
    local _plan_file="$_target/.bashagt/plan.md"
    mkdir -p "$_target/.bashagt" 2>/dev/null || true
    # Trace: record plan changes
    if [[ "${BASHAGT_TRACE_ENABLED:-1}" == "1" ]]; then
        local _old_plan; _old_plan=$(cat "$_plan_file" 2>/dev/null || echo "")
        if [[ "$_old_plan" != "$_plan_output" ]]; then
            trace_record "$_plan_file" "$_old_plan" "$_plan_output" \
                "$(jq -nc --arg agent "${AGENT_SELF_NAME:-main}" --arg tool "plan" \
                   --arg desc "save plan" --argjson turn "${TURN_COUNT:-0}" \
                   '{agent:$agent, tool:$tool, turn:$turn, desc:$desc}')" 2>/dev/null || true
        fi
    fi
    printf '%s\n' "$_plan_output" > "$_plan_file"
    PLAN_LAST_FORMATTED="$_plan_output"
    local _bytes; _bytes=$(wc -c < "$_plan_file")
    log "DEBUG: PLAN_SAVE   bytes=$_bytes"
    _stream_emit "info" "$(jq -nc --arg c "Plan saved to $_plan_file ($_bytes bytes)" '{content: $c}')"
}

# Derive plan state from file + plan-sourced TODOs
# Returns: idle | active | done
_plan_state() {
    local _target="${1:-$PWD}" _plan_file="$_target/.bashagt/plan.md"
    if [[ ! -f "$_plan_file" ]]; then
        printf 'idle'
        return 0
    fi
    # Check if there are plan-sourced TODOs
    local _todo_file="$_target/.bashagt/todo.json"
    if [[ -f "$_todo_file" ]]; then
        local _plan_total _plan_done
        _plan_total=$(jq '[.[] | select(.source=="plan")] | length' "$_todo_file" 2>/dev/null || echo 0)
        _plan_done=$(jq '[.[] | select(.source=="plan" and .status=="completed")] | length' "$_todo_file" 2>/dev/null || echo 0)
        if (( _plan_total > 0 )); then
            if (( _plan_done >= _plan_total )); then
                printf 'done'
            else
                printf 'active'
            fi
            return 0
        fi
    fi
    # Plan file exists but no TODOs created yet
    printf 'active'
}

# ── Plan auto TODO: non-interactive TODO creation from plan steps ──
# Extracted from plan_to_todos() logic, no interactive prompts.
_plan_auto_todo() {
    local _steps_raw="$1" _target="${2:-$PWD}" _plan_id="${3:-}"
    local _date _id _desc_json _tmp_items _new_items _pi _plan_created _count
    _date=$(date +%Y-%m-%d)
    _pi=0
    _plan_created=''
    _tmp_items=$(_mktemp_file /tmp/bashagt_plan_todos.XXXXXX 2>/dev/null)
    if [[ -z "$_tmp_items" ]] || [[ ! -f "$_tmp_items" ]]; then
        log "WARN: mktemp failed for plan TODOs, falling back to individual saves"
        while IFS= read -r -d '' _step; do
            _pi=$((_pi + 1))
            _id="todo_$(_timestamp_ms)${RANDOM}_${_pi}"
            _desc_json=$(printf '%s' "" | jq -Rs '.')
            _new_json=$(jq -n --arg id "$_id" --arg subject "$_step" \
                --argjson desc "$_desc_json" --arg pid "$_plan_id" \
                --arg date "$_date" \
                '[{id:$id,subject:$subject,description:$desc,status:"pending",source:"plan",created:$date,completed:"",plan_id:$pid}]')
            TODOS=$(echo "$TODOS" | jq ". + $_new_json")
            save_todos
            _plan_created+=$(printf '  %s%d.%s %s' "$GREEN" "$_pi" "$RESET" "$_step")$'\n'
        done < <(printf '%s' "$_steps_raw" | jq -j '.[] + "\u0000"' 2>/dev/null)
        _count=$_pi
        local _plain_created
        _plain_created=$(printf '%s' "$_plan_created" | sed $'s/\033\[[0-9;?]*[a-zA-Z]//g')
        local _feedback=""
        [[ -n "$_plain_created" ]] && _feedback+="$_plain_created"
        _feedback+=$'\n'"  Created $_count TODO items."
        printf '%s\n%s' "$_feedback" "$_count"
        return 0
    fi
    # Batch creation via mktemp
    while IFS= read -r -d '' _step; do
        _pi=$((_pi + 1))
        _id="todo_$(_timestamp_ms)${RANDOM}_${_pi}"
        jq -n --arg id "$_id" --arg subject "$_step" \
            --arg date "$_date" --arg pid "$_plan_id" \
            '{id:$id,subject:$subject,description:"",status:"pending",source:"plan",created:$date,completed:"",plan_id:$pid}' \
            >> "$_tmp_items"
    done < <(printf '%s' "$_steps_raw" | jq -j '.[] + "\u0000"' 2>/dev/null)
    _new_items=$(jq -s '.' "$_tmp_items")
    rm -f "$_tmp_items"
    TODOS=$(echo "$TODOS" | jq ". + $_new_items")
    save_todos
    # Echo created items
    _pi=0
    _plan_created=''
    while IFS= read -r -d '' _step; do
        _pi=$((_pi + 1))
        _plan_created+=$(printf '  %s%d.%s %s' "$GREEN" "$_pi" "$RESET" "$_step")$'\n'
    done < <(printf '%s' "$_steps_raw" | jq -j '.[] + "\u0000"' 2>/dev/null)
    _count=$_pi
    # Auto-start first pending plan-sourced TODO (must precede _feedback build)
    local _first_id
    _first_id=$(echo "$TODOS" | jq -r --arg pid "$_plan_id" \
        '[.[] | select(.source=="plan" and .plan_id==$pid and .status=="pending")] | sort_by(.created) | .[0].id // empty' 2>/dev/null)
    if [[ -n "$_first_id" ]]; then
        todo_update "$_first_id" "in_progress" > /dev/null || true
    fi
    # Build plain-text feedback (deferred to caller — no _stream_emit here)
    local _feedback="" _plain_created
    _plain_created=$(printf '%s' "$_plan_created" | sed $'s/\033\[[0-9;?]*[a-zA-Z]//g')
    [[ -n "$_plain_created" ]] && _feedback+="$_plain_created"
    _feedback+=$'\n'"  Created $_count TODO items."
    if [[ -n "$_first_id" ]]; then
        _feedback+=$'\n'"  $(ui_label '▶' cyan) Auto-started first task."
        local _s1_subj; _s1_subj=$(echo "$TODOS" | jq -r --arg id "$_first_id" '.[] | select(.id==$id) | .subject' 2>/dev/null)
        _feedback+=$'\n'"$(ui_label '⚑ Step 1' pink) · $_s1_subj"
    fi
    printf '%s\n%s' "$_feedback" "$_count"
}

# ── Plan response handler ──
# Parses _request_ui result JSON: {result, choice, choice_index, total_options}
_plan_handle_response() {
    local _choice_json="$1" _target="${2:-$PWD}" _result _choice_idx
    _result=$(jq -r '.result // "cancelled"' <<< "$_choice_json" 2>/dev/null)
    _choice_idx=$(jq -r '.choice_index // -1' <<< "$_choice_json" 2>/dev/null)

    if [[ "$_result" == "cancelled" ]]; then
        _stream_emit "warning" "$(jq -nc --arg c 'Plan cancelled.' '{content: $c}')"
        log "DEBUG: PLAN_CONFIRM action=cancelled"
        _PLAN_STOP=1
        return 1
    fi

    case "$_choice_idx" in
        0)  # Approve → defer to main agent for verification
            log "DEBUG: PLAN_CONFIRM action=approved"
            _PLAN_DEFERRED_MSG="Plan approved and saved to .bashagt/plan.md.

Before creating TODO items, verify the plan:
1. Read the plan with: read_file(\".bashagt/plan.md\")
2. Check it addresses the user's requirements correctly
3. Verify implementation steps are complete and feasible
4. Identify any missing steps, incorrect assumptions, or risks
5. If issues found, suggest specific modifications now
6. When satisfied, call: make_todos(plan_text) to extract steps and create TODO items"
            ;;
        1)  # Reject — stop turn, return to REPL (don't continue API loop)
            _stream_emit "warning" "$(jq -nc --arg c 'Plan rejected by user.' '{content: $c}')"
            log "DEBUG: PLAN_CONFIRM action=rejected"
            _PLAN_STOP=1
            ;;
        *)
            _stream_emit "warning" "$(jq -nc --arg c "Plan confirmation: unknown response (${_choice_idx})." '{content: $c}')"
            _PLAN_STOP=1
            ;;
    esac
    return 0
}

# Extract plan steps via sub-agent, prompt, create TODO items
plan_to_todos() {
    local _extract_tmp _ep _erc _elapsed _start_ts
    _extract_tmp=$(_mktemp_file) || { local raw; raw=$(plan_extract_steps 2>/dev/null) || true; }
    if [[ -n "${_extract_tmp:-}" ]]; then
        _start_ts=$(_timestamp_ms)
        plan_extract_steps > "$_extract_tmp" 2>/dev/null &
        _ep=$!
        _proc_register "$_ep" "agent" "plan_extract"
        _stream_emit "status_begin" "$(jq -nc \
            --arg icon "$_DOT_FRAME" --arg label "list-plan" --arg ela "0s" \
            '{icon: $icon, label: $label, elapsed_str: $ela}')"
        local _tick_count=0
        while kill -0 $_ep 2>/dev/null; do
            _elapsed=$(( $(_timestamp_ms) - _start_ts ))
            (( _elapsed < 0 )) && _elapsed=0
            (( _tick_count % 5 == 0 )) && _dot_tick     # flash at 0.5s
            _stream_emit "status_update" "$(jq -nc \
                --arg icon "$_DOT_FRAME" --arg label "list-plan" \
                --arg ela "$(ui_time $_elapsed)" \
                '{icon: $icon, label: $label, elapsed_str: $ela}')"
            _tick_count=$((_tick_count + 1))
            _spin_sleep || { kill "$_ep" 2>/dev/null || true; break; }
        done
        wait $_ep; _erc=$?
        _elapsed=$(( $(_timestamp_ms) - _start_ts ))
        (( _elapsed < 0 )) && _elapsed=0
        raw=$(< "$_extract_tmp"); rm -f "$_extract_tmp"
        local _pl_label="list-plan-done"
        if (( _erc != 0 )) || [[ -z "$raw" ]]; then
            _pl_label="list-plan-error"
        fi
        _stream_emit "status_done" "$(jq -nc \
            --arg label "$_pl_label" --arg ela "$(ui_time $_elapsed)" \
            '{label: $label, elapsed_str: $ela}')"
    else
        local raw; raw=$(plan_extract_steps 2>/dev/null) || true
    fi
    if [[ -z "$raw" ]]; then
        _stream_emit "warning" "$(jq -nc --arg c 'No plan steps found in conversation history.' '{content: $c}')"
        _stream_emit "info" "$(jq -nc --arg c 'The model may not have produced a plan yet.' '{content: $c}')"
        _stream_emit "text" "$(jq -nc --arg c "  ${BOLD}Create TODO items anyway? [y/N]${RESET} " '{content: $c}')"
        local answer=""
        read -r -n1 answer 2>/dev/null || answer=""
        printf '\n'
        if [[ "$answer" != "y" ]] && [[ "$answer" != "Y" ]]; then
            _stream_emit "text" "$(jq -nc --arg c "  ${DIM}Skipped. No TODO items created.${RESET}" '{content: $c}')"
            return 1
        fi
        _stream_emit "text" "$(jq -nc --arg c "  ${DIM}Proceeding with no TODO items.${RESET}" '{content: $c}')"
        return 0
    fi

    # Detect format: JSON array from plan_extract_steps
    local i=1 _step
    local count
    count=$(printf '%s' "$raw" | jq -r 'length' 2>/dev/null)
    local _plan_disp=''
    _plan_disp+=$(printf '  %sPlan contains %d steps:%s' "$BOLD" "$count" "$RESET")$'\n'

    # Display steps — NUL-separated iteration (preserves embedded newlines)
    while IFS= read -r -d '' _step; do
        _plan_disp+=$(printf '    %s%d.%s %s' "$DIM" "$i" "$RESET" "$_step")$'\n'
        i=$((i + 1))
    done < <(printf '%s' "$raw" | jq -j '.[] + "\u0000"' 2>/dev/null)
    _stream_emit "text" "$(jq -nc --arg c "$_plan_disp" '{content: $c}')"

    _stream_emit "text" "$(jq -nc --arg c "  ${BOLD}Create $count TODO items from this plan? [Y/n]${RESET} " '{content: $c}')"
    local answer=""
    read -r -n1 answer 2>/dev/null || answer=""
    printf '\n'
    if [[ -z "$answer" ]] || [[ "$answer" == "y" ]] || [[ "$answer" == "Y" ]]; then
        local _date _id _desc_json _tmp_items _new_items _pi _plan_created=''
        _date=$(date +%Y-%m-%d)
        _tmp_items=$(_mktemp_file /tmp/bashagt_plan_todos.XXXXXX 2>/dev/null)
        if [[ -z "$_tmp_items" ]] || [[ ! -f "$_tmp_items" ]]; then
            log "WARN: mktemp failed, falling back to individual saves"
            _pi=0
            while IFS= read -r -d '' _step; do
                todo_add "$_step" "" "plan" > /dev/null
                _plan_created+=$(printf '  %s%d.%s %s' "$GREEN" "$_pi" "$RESET" "$_step")$'\n'
                _pi=$((_pi + 1))
            done < <(printf '%s' "$raw" | jq -j '.[] + "\u0000"' 2>/dev/null)
            [[ -n "$_plan_created" ]] && _stream_emit "text" "$(jq -nc --arg c "$_plan_created" '{content: $c}')"
            _stream_emit "text" "$(jq -nc --arg c "  Created $_pi TODO items." '{content: $c}')"
            return 0
        fi
        _pi=0
        _plan_created=''
        while IFS= read -r -d '' _step; do
            _pi=$((_pi + 1))
            _id="todo_$(_timestamp_ms)${RANDOM}_${_pi}"
            _desc_json=$(printf '%s' "$_step" | jq -Rs '.')
            jq -n --arg id "$_id" --arg subject "$_step" \
                --argjson desc "$_desc_json" --arg date "$_date" \
                '{id:$id,subject:$subject,description:$desc,status:"pending",source:"plan",created:$date,completed:""}' \
                >> "$_tmp_items"
        done < <(printf '%s' "$raw" | jq -j '.[] + "\u0000"' 2>/dev/null)
        _new_items=$(jq -s '.' "$_tmp_items")
        rm -f "$_tmp_items"
        TODOS=$(echo "$TODOS" | jq ". + $_new_items")
        save_todos
        # Echo back created items
        _pi=0
        while IFS= read -r -d '' _step; do
            _pi=$((_pi + 1))
            _plan_created+=$(printf '  %s%d.%s %s' "$GREEN" "$_pi" "$RESET" "$_step")$'\n'
        done < <(printf '%s' "$raw" | jq -j '.[] + "\u0000"' 2>/dev/null)
        [[ -n "$_plan_created" ]] && _stream_emit "text" "$(jq -nc --arg c "$_plan_created" '{content: $c}')"
        _stream_emit "text" "$(jq -nc --arg c "  Created $_pi TODO items." '{content: $c}')"
        # Auto-mark first pending plan-sourced TODO as in_progress
        local _first_id; _first_id=$(echo "$TODOS" | jq -r '[.[] | select(.source=="plan" and .status=="pending")] | sort_by(.created) | .[0].id // empty' 2>/dev/null)
        if [[ -n "$_first_id" ]]; then
            todo_update "$_first_id" "in_progress" > /dev/null || true
            _stream_emit "text" "$(jq -nc --arg c "  $(ui_label '▶' cyan) Auto-started first task." '{content: $c}')"
        fi
    else
        _stream_emit "text" "$(jq -nc --arg c '  Skipped.' '{content: $c}')"
    fi
}

# Agent-accessible tool: create TODOs from a plan document.
# Runs plan_extract_steps → _plan_auto_todo pipeline.
tool_make_todos() {
    local plan_text="$1" desc="${2:-}"
    local _steps_raw _count _target _plan_id
    _target="${BASHAGT_PROJECT_DIR:-$PWD}"
    _plan_id="plan_$(_timestamp_ms)"

    [[ -z "$plan_text" ]] && { printf 'Error: plan_text required for make_todos\n'; return 1; }

    # Save plan text to plan.md (mirrors _plan_save)
    local _plan_file="$_target/.bashagt/plan.md"
    mkdir -p "$_target/.bashagt" 2>/dev/null || true
    if [[ "${BASHAGT_TRACE_ENABLED:-1}" == "1" ]]; then
        local _old_plan; _old_plan=$(cat "$_plan_file" 2>/dev/null || echo "")
        if [[ "$_old_plan" != "$plan_text" ]]; then
            trace_record "$_plan_file" "$_old_plan" "$plan_text" \
                "$(jq -nc --arg agent "${AGENT_SELF_NAME:-main}" --arg tool "make_todos" \
                   --arg desc "${BASHAGT_TRACE_DESC:-}" --argjson turn "${TURN_COUNT:-0}" \
                   '{agent:$agent, tool:$tool, turn:$turn, desc:$desc}')" 2>/dev/null || true
        fi
    fi
    printf '%s\n' "$plan_text" > "$_plan_file"
    PLAN_LAST_FORMATTED="$plan_text"

    # Extract steps via LLM extractor
    _steps_raw=$(plan_extract_steps "$plan_text" "$desc" 2>/dev/null) || _steps_raw=""
    if [[ -z "$_steps_raw" ]]; then
        printf 'Error: plan_extract_steps returned no steps from plan text\n'
        return 1
    fi

    # Create TODOs
    local _full_output
    _full_output=$(_plan_auto_todo "$_steps_raw" "$_target" "$_plan_id" 2>/dev/null) || true
    # Last line = count, preceding lines = feedback text
    _count=$(printf '%s\n' "$_full_output" | tail -1)
    _count=${_count:-0}
    local _fb; _fb=$(printf '%s\n' "$_full_output" | sed '$d')
    _fb="${_fb%$'\n'}"  # strip trailing newline from sed output
    if [[ -n "$_fb" ]]; then
        [[ -n "$_DEFERRED_FEEDBACK" ]] && _DEFERRED_FEEDBACK+=$'\n'
        _DEFERRED_FEEDBACK+="$_fb"
    fi
    load_todos  # sync parent TODOS from disk after subshell isolation

    # Invalidate tools cache (plan state changed: idle → active)
    invalidate_tools_cache

    # Return created items as JSON
    local _items
    _items=$(echo "$TODOS" | jq --argjson n "$_count" \
        '[.[] | select(.source=="plan")] | sort_by(.created) | .[-($n):] |
         .[] | {id, subject, status}' 2>/dev/null)
    printf '%s\n' "$_items" | jq -s --argjson n "$_count" '{count: $n, items: .}'
}

# Agent-accessible tool: create a task
tool_task_create() {
    local subject="$1" description="${2:-}"
    [[ -z "$subject" ]] && { printf 'Error: subject required for task_create\n'; return 1; }
    todo_add "$subject" "$description" "agent" > /dev/null
    local count; count=$(echo "$TODOS" | jq 'length')
    printf 'Task created: [%s] %s (%d total tasks)\n' "${_LAST_TODO_ID:5:13}" "$subject" "$count"
}

# Agent-accessible tool: update a task
tool_task_update() {
    local id="$1" status="${2:-}" subject="${3:-}" description="${4:-}"
    if [[ -z "$id" ]]; then
        printf 'ERROR: task id is required (empty string received).\n'
        return 1
    fi
    if todo_update "$id" "$status" "$subject" "$description"; then
        if [[ "$status" == "completed" ]]; then
            local subj; subj=$(echo "$TODOS" | jq -r --arg id "$id" '.[] | select(.id == $id) | .subject')
            printf 'Task completed: %s\n' "$subj"
        elif [[ "$status" == "failed" ]]; then
            local subj; subj=$(echo "$TODOS" | jq -r --arg id "$id" '.[] | select(.id == $id) | .subject')
            printf 'Task failed: %s\n' "$subj"
        elif [[ "$status" == "in_progress" ]]; then
            printf 'Task in progress: %s\n' "$id"
            local _st_src; _st_src=$(echo "$TODOS" | jq -r --arg id "$id" '.[] | select(.id==$id) | .source // ""' 2>/dev/null)
            local _st_num _st_subj
            _st_subj=$(echo "$TODOS" | jq -r --arg id "$id" '.[] | select(.id==$id) | .subject' 2>/dev/null)
            if [[ "$_st_src" == "plan" ]]; then
                _st_num=$(echo "$TODOS" | jq -r --arg id "$id" '
                  ([.[] | select(.id==$id) | .plan_id // ""] | .[0]) as $target_pid |
                  [.[] | select(.source=="plan" and (.plan_id // "") == $target_pid)] |
                  sort_by(.created) |
                  to_entries | .[] | select(.value.id==$id) | (.key + 1)' 2>/dev/null)
                if [[ -n "$_st_num" && "$_st_num" != "null" ]]; then
                    _STEP_BANNER="$(ui_label "⚑ Step $_st_num" pink) · $_st_subj"
                fi
            else
                _STEP_BANNER="$(ui_label '⚑' pink) $_st_subj"
            fi
        fi
        if [[ -n "$subject" ]]; then
            printf 'Task updated: %s\n' "$id"
        fi
    else
        printf 'Error: Task "%s" not found.\n' "$id"
    fi
}

# Agent-accessible tool: list all tasks
tool_task_list() {
    local total status_char
    total=$(echo "$TODOS" | jq 'length')
    if (( total == 0 )); then
        printf 'No tasks yet. Use make_todos with a plan document to create tasks.\n'
        return 0
    fi
    printf '%d task(s):\n' "$total"
    local _items; _items=$(echo "$TODOS" | jq -r \
        'to_entries | .[] | "\(.value.status)\t\(.value.id)\t\(.value.subject | gsub("\t";" "))"')
    local _status _id _subject
    while IFS=$'\t' read -r _status _id _subject; do
        [[ -z "$_id" ]] && continue
        case "$_status" in
            in_progress) status_char="${CYAN}[>]${RESET}";;
            completed)   status_char="${GREEN}[done]${RESET}";;
            failed)      status_char="${RED}[failed]${RESET}";;
            *)           status_char="${YELLOW}[ ]${RESET}";;
        esac
        printf '  %s [%s] %s\n' "$status_char" "${_id:5:16}" "$_subject"
    done <<< "$_items"
}

# ============================================================================
# SECTION 7e: Unified Sub-Agent Scheduling — job queue, scheduler, worker pool
# ============================================================================

# ── Discovery ──

# Expand discovers globs and cache in AGENT_DISCOVERS. Called after load_agents
# and _register_engram_agents. Glob patterns: "engram_*" → all engram agents,
# "*" → all non-engram agents.
_agent_refresh_discovery() {
    local name _meta _disc _peer _expanded _engram_names=""
    for name in "${!AGENTS[@]}"; do
        [[ "$name" == engram_* ]] && _engram_names+="${_engram_names:+ }$name"
    done

    for name in "${!AGENTS[@]}"; do
        _meta="${AGENT_META[$name]:-}"
        # Skip agents without discovers field (most agents — saves jq fork)
        [[ "$_meta" != *'"discovers"'* ]] && { AGENT_DISCOVERS[$name]='[]'; AGENT_STATUS[$name]="${AGENT_STATUS[$name]:-idle}"; continue; }
        _disc=$(jq -r '(.discovers // []) | join("\n")' <<< "$_meta" 2>/dev/null)
        _expanded=""
        while IFS= read -r _peer; do
            [[ -z "$_peer" ]] && continue
            case "$_peer" in
                "engram_*")
                    local _en
                    for _en in $_engram_names; do
                        [[ -n "$_expanded" ]] && _expanded+=$'\n'
                        _expanded+="$_en"
                    done ;;
                "*")
                    local _n2
                    for _n2 in "${!AGENTS[@]}"; do
                        [[ "$_n2" == "$name" ]] && continue
                        [[ "$_n2" == engram_* || "$_n2" == "format" || "$_n2" == "plan_extractor" ]] && continue
                        [[ -n "$_expanded" ]] && _expanded+=$'\n'
                        _expanded+="$_n2"
                    done ;;
                *)
                    [[ -n "${AGENTS[$_peer]:-}" ]] && {
                        [[ -n "$_expanded" ]] && _expanded+=$'\n'
                        _expanded+="$_peer"
                    } ;;
            esac
        done <<< "$_disc"
        AGENT_DISCOVERS[$name]=$(printf '%s\n' "$_expanded" | jq -R -s 'split("\n") | map(select(. != ""))' 2>/dev/null || echo '[]')
        AGENT_STATUS[$name]="${AGENT_STATUS[$name]:-idle}"
    done
}

# agent_status() → JSON [{name, description, status}]
# Main agent calls this via the agent_status tool.
agent_status() {
    local _name _result="[" _first=1
    for _name in "${!AGENTS[@]}"; do
        [[ "$_name" == engram_* || "$_name" == "format" || "$_name" == "plan_extractor" ]] && continue
        local _desc; _desc=$(echo "${AGENT_META[$_name]}" | jq -r '.description // ""' 2>/dev/null)
        local _st="${AGENT_STATUS[$_name]:-idle}"
        [[ $_first -eq 1 ]] && _first=0 || _result+=","
        _result+=$(jq -n --arg name "$_name" --arg desc "$_desc" --arg st "$_st" \
            '{name:$name, description:$desc, status:$st}')
    done
    _result+="]"
    printf '%s' "$_result"
}

# ── Job Queue ──

_job_generate_id() {
    local _agent="${1:-sub}" _ts _rand
    _ts=${EPOCHSECONDS:-$(date +%s 2>/dev/null || echo "0")}
    _rand=$(printf '%04x' $((RANDOM & 0xFFFF)) 2>/dev/null || echo "0000")
    printf 'job_%s_%s_%s' "$_agent" "$_ts" "$_rand"
}

_job_update_status() {
    local job_id="$1" status="$2"
    local job_file="$JOBS_DIR/${job_id}.json"
    [[ -f "$job_file" ]] || return 1
    local _extra_jq="${3:-}"
    local _tmp="${job_file}.tmp.$$"
    if [[ -n "$_extra_jq" ]]; then
        jq --arg status "$status" ".status = \$status | $_extra_jq" "$job_file" > "$_tmp" 2>/dev/null \
            && mv "$_tmp" "$job_file" 2>/dev/null || rm -f "$_tmp"
    else
        jq --arg status "$status" '.status = $status' "$job_file" > "$_tmp" 2>/dev/null \
            && mv "$_tmp" "$job_file" 2>/dev/null || rm -f "$_tmp"
    fi
}

# ── Public Job API ──

agent_submit() {
    local agent="$1" prompt="$2"
    [[ -z "${AGENTS[$agent]:-}" ]] && { printf '{"error":"Agent not found: %s"}\n' "$agent"; return 1; }
    mkdir -p "$JOBS_DIR"
    local job_id; job_id=$(_job_generate_id "$agent")
    local result_file; result_file=$(_mktemp_file "/tmp/bashagt_job_${job_id}.XXXXXX" 2>/dev/null || echo "${BASHAGT_TMPDIR:-/tmp}/bashagt_job_${job_id}.out")
    local _now; _now=${EPOCHSECONDS:-$(date +%s 2>/dev/null || echo 0)}
    jq -n --arg id "$job_id" --arg agent "$agent" --arg prompt "$prompt" \
        --arg rf "$result_file" --argjson now "$_now" \
        '{id:$id, agent:$agent, prompt:$prompt, status:"queued",
          created_at:$now, started_at:0, finished_at:0, pid:0,
          result_file:$rf, result_size:0, error:""}' \
        > "$JOBS_DIR/${job_id}.json"
    _agent_sched_tick
    printf '%s' "$job_id"
}

agent_poll() {
    local job_id="$1"
    local job_file="$JOBS_DIR/${job_id}.json"
    [[ -f "$job_file" ]] || { printf 'unknown'; return 0; }
    jq -r '.status // "unknown"' "$job_file" 2>/dev/null || printf 'unknown'
}

agent_result() {
    local job_id="$1" timeout="${2:-30}"
    local job_file="$JOBS_DIR/${job_id}.json"
    [[ -f "$job_file" ]] || return 1
    local _waited=0
    while [[ $_waited -lt $((timeout * 2)) ]]; do
        _interrupted && { printf '[interrupted]\n'; return 1; }
        local _st; _st=$(jq -r '.status // ""' "$job_file" 2>/dev/null)
        case "$_st" in
            done)
                local _rf; _rf=$(jq -r '.result_file // ""' "$job_file" 2>/dev/null)
                [[ -n "$_rf" && -f "$_rf" ]] && { cat "$_rf"; rm -f "$_rf"; }
                return 0 ;;
            failed|cancelled)
                jq -r '.error // "job failed"' "$job_file" 2>/dev/null
                return 1 ;;
            *) sleep 0.5; _waited=$((_waited + 1)) ;;
        esac
    done
    return 1
}

# ── Built-in pre_turn hook: inject pending job summary ──
# Scans JOBS_DIR for queued/running jobs and injects a status summary
# into _HOOK_CONTEXT_BUFFER so the LLM always knows which async jobs are active.
_hook_job_context() {
    local _dir="${JOBS_DIR:-}"
    [[ -d "$_dir" ]] || { jq -nc '{inject:false}'; return; }

    # Single jq invocation extracts all fields from all job files at once
    local _now _active=0 _summary="" _jobs_json
    _now=${EPOCHSECONDS:-$(date +%s 2>/dev/null || echo 0)}
    _jobs_json=$(jq -s '[.[] | {status, id, agent, prompt, created_at, result_file}]' "$_dir"/*.json 2>/dev/null || echo '[]')

    if [[ "$_jobs_json" == "[]" || -z "$_jobs_json" ]]; then
        jq -nc '{inject:false}'; return
    fi

    # Extract all fields in one jq call: tab-separated lines
    while IFS=$'\t' read -r _st _id _agent _prompt _created _rf; do
        [[ -z "$_st" ]] && continue
        case "$_st" in
            queued|running)
                _active=$((_active + 1))
                _elapsed=$((_now - ${_created:-0}))
                (( _elapsed < 0 )) && _elapsed=0
                (( ${#_prompt} > 200 )) && _prompt="${_prompt:0:197}..."
                _summary+="  [${_st} ${_elapsed}s] ${_id:-?} → ${_agent:-?}: \"${_prompt}\""$'\n'
                ;;
            done)
                if [[ -n "$_rf" && -f "$_rf" ]]; then
                    _summary+="  [ready] ${_id:-?} → ${_agent:-?} (result available)"$'\n'
                fi
                ;;
        esac
    done < <(jq -r '.[] | [.status//"", .id//"?", .agent//"?", .prompt//"", (.created_at//0|tostring), .result_file//""] | @tsv' <<< "$_jobs_json" 2>/dev/null)

    if (( _active == 0 )) && [[ -z "$_summary" ]]; then
        jq -nc '{inject:false}'
        return
    fi

    local _ctx=""
    if (( _active > 0 )); then
        _ctx+="⚡ PENDING ASYNC JOBS (${_active} active):"$'\n'
        _ctx+="$_summary"$'\n'
        _ctx+="  job_poll(\"<id>\") → check status  |  job_result(\"<id>\") → collect when done  |  job_cancel(\"<id>\") → abort"$'\n'
    fi
    if [[ -n "$_summary" && $_active -eq 0 ]]; then
        _ctx+="📬 COMPLETED JOBS READY FOR COLLECTION:"$'\n'
        _ctx+="$_summary"$'\n'
        _ctx+="  job_result(\"<id>\") → collect output"$'\n'
    fi

    jq -nc --arg c "$_ctx" '{inject:true, role:"user", content:$c}'
}

agent_cancel() {
    local job_id="$1"
    local job_file="$JOBS_DIR/${job_id}.json"
    [[ -f "$job_file" ]] || return 1
    local _st; _st=$(jq -r '.status' "$job_file" 2>/dev/null)
    case "$_st" in
        queued)
            _job_update_status "$job_id" "cancelled" '.finished_at = (now | floor)'
            return 0 ;;
        running)
            local _pid; _pid=$(jq -r '.pid // 0' "$job_file" 2>/dev/null)
            [[ "$_pid" -gt 0 ]] && { _pkill_tree "$_pid" TERM 2>/dev/null || true; sleep 2; _pkill_tree "$_pid" KILL 2>/dev/null || true; }
            _job_update_status "$job_id" "cancelled" '.finished_at = (now | floor)'
            local _ag; _ag=$(jq -r '.agent // ""' "$job_file" 2>/dev/null)
            [[ -n "$_ag" ]] && AGENT_STATUS[$_ag]="idle"
            return 0 ;;
        *) return 1 ;;
    esac
}

agent_list() {
    local filter="${1:-}"
    [[ -d "$JOBS_DIR" ]] || { printf '[]'; return 0; }
    local _files; _files=$(ls "$JOBS_DIR"/*.json 2>/dev/null || true)
    [[ -z "$_files" ]] && { printf '[]'; return 0; }
    if [[ -n "$filter" ]]; then
        jq --arg f "$filter" 'select(.status == $f)' $_files 2>/dev/null | jq -s '.'
    else
        jq -s '.' $_files 2>/dev/null || echo '[]'
    fi
}



# ── Scheduler ──

_agent_sched_init() {
    JOBS_DIR="${BASHAGT_PROJECT_DIR:-.}/.bashagt/jobs"
    mkdir -p "$JOBS_DIR"
    _agent_sched_reap  # clean any stale running jobs from previous crash
}

_agent_sched_tick() {
    [[ -d "$JOBS_DIR" ]] || return
    _agent_sched_reap
    local _max="${BASHAGT_MAX_SUBAGENTS:-4}"
    # Single jq pass: count running, find oldest queued
    local _info
    _info=$(for f in "$JOBS_DIR"/*.json; do
        [[ -f "$f" ]] || continue
        jq -c '{id, status, created_at}' "$f" 2>/dev/null
    done | jq -s '{running: [.[] | select(.status=="running")] | length,
                   queued: [.[] | select(.status=="queued")] | sort_by(.created_at) | [.[].id]}' 2>/dev/null)
    [[ -z "$_info" ]] && return
    local _running; _running=$(jq -r '.running // 0' <<< "$_info")
    local _slots=$((_max - _running))
    log "DEBUG: [AGENT] sched_tick: running=$_running queued=$(jq -r '.queued | length' <<< "$_info") slots=$_slots max=$_max"
    [[ $_slots -le 0 ]] && return
    local _q_ids; _q_ids=$(jq -r ".queued[:${_slots}] | .[]" <<< "$_info" 2>/dev/null)
    [[ -z "$_q_ids" ]] && return
    local _qid
    while IFS= read -r _qid; do
        [[ -z "$_qid" ]] && continue
        _agent_sched_launch "$_qid"
    done <<< "$_q_ids"
}

_agent_sched_reap() {
    [[ -d "$JOBS_DIR" ]] || return
    local _job_file _pid _jid _st _rf _ag
    for _job_file in "$JOBS_DIR"/*.json; do
        [[ -f "$_job_file" ]] || continue
        _st=$(jq -r '.status // ""' "$_job_file" 2>/dev/null)
        [[ "$_st" == "running" ]] || continue
        _pid=$(jq -r '.pid // 0' "$_job_file" 2>/dev/null)
        [[ "$_pid" -le 0 ]] && continue
        if ! kill -0 "$_pid" 2>/dev/null; then
            _jid=$(jq -r '.id' "$_job_file" 2>/dev/null)
            _rf=$(jq -r '.result_file // ""' "$_job_file" 2>/dev/null)
            _ag=$(jq -r '.agent // ""' "$_job_file" 2>/dev/null)
            if [[ -n "$_rf" && -s "$_rf" ]]; then
                local _sz; _sz=$(wc -c < "$_rf" 2>/dev/null || echo 0)
                _job_update_status "$_jid" "done" ".finished_at = (now | floor) | .result_size = $_sz"
            else
                _job_update_status "$_jid" "failed" '.finished_at = (now | floor) | .error = "Worker process exited"'
            fi
            [[ -n "$_ag" ]] && AGENT_STATUS[$_ag]="idle"
        fi
    done
}

_agent_sched_launch() {
    local job_id="$1"
    local job_file="$JOBS_DIR/${job_id}.json"
    [[ -f "$job_file" ]] || return 1
    local _ag; _ag=$(jq -r '.agent // ""' "$job_file" 2>/dev/null)
    log "DEBUG: [AGENT] sched_launch: job=$job_id agent=$_ag"
    AGENT_STATUS[$_ag]="busy"
    _job_update_status "$job_id" "running" '.started_at = (now | floor)'
    if [[ "$_ag" == "bash" ]]; then
        ( _agent_worker_bash "$job_id" ) </dev/null >/dev/null 2>&1 8>&1 &
    else
        ( _agent_worker "$job_id" ) </dev/null >/dev/null 2>&1 8>&1 &
    fi
    local _wpid=$!
    jq --argjson pid "$_wpid" '.pid = $pid' "$job_file" > "${job_file}.tmp" 2>/dev/null \
        && mv "${job_file}.tmp" "$job_file" 2>/dev/null || rm -f "${job_file}.tmp"
}

# ── Worker ──

_agent_worker() {
    local job_id="$1"
    local job_file="$JOBS_DIR/${job_id}.json"
    local agent prompt result_file
    agent=$(jq -r '.agent // ""' "$job_file" 2>/dev/null)
    prompt=$(jq -r '.prompt // ""' "$job_file" 2>/dev/null)
    result_file=$(jq -r '.result_file // ""' "$job_file" 2>/dev/null)

    log "DEBUG: [AGENT] worker_start: job=$job_id agent=$agent pid=$$"
    export AGENT_SELF_NAME="$agent"

    local _wrc=0
    if _call_agent_core "$agent" "$prompt" > "$result_file" 2>/dev/null; then
        local _sz; _sz=$(wc -c < "$result_file" 2>/dev/null || echo 0)
        _job_update_status "$job_id" "done" ".finished_at = (now | floor) | .result_size = $_sz"
    else
        _wrc=1
        _job_update_status "$job_id" "failed" '.finished_at = (now | floor) | .error = "Agent returned error"'
    fi
    AGENT_STATUS[$agent]="idle"
    log "DEBUG: [AGENT] worker_done: job=$job_id agent=$agent rc=$_wrc"
}

# ── Bash background worker ──
_agent_worker_bash() {
    local job_id="$1"
    local job_file="$JOBS_DIR/${job_id}.json"
    local command timeout result_file
    command=$(jq -r '.prompt // ""' "$job_file" 2>/dev/null)
    timeout=$(jq -r '.timeout // 120' "$job_file" 2>/dev/null)
    result_file=$(jq -r '.result_file // ""' "$job_file" 2>/dev/null)

    log "DEBUG: [BASH] bg_start: job=$job_id cmd=${command:0:80}"

    local _pid _pgid
    if command -v setsid &>/dev/null; then
        setsid bash -c "$command" > "$result_file" 2>&1 &
    else
        bash -c "$command" > "$result_file" 2>&1 &
    fi
    _pid=$!
    _proc_register "$_pid" "agent" "worker: ${job_id:0:40}"
    _pgid=$(ps -o pgid= -p $_pid 2>/dev/null | tr -d ' ')
    [[ -z "$_pgid" ]] && _pgid=$_pid

    # Record PGID for crash recovery
    local _track_pid="${BASHAGT_DAEMON_PID:-$$}"
    local _pgid_file="${TMPDIR:-/tmp}/bashagt_pgid_${_track_pid}_${_pgid}"
    printf '' > "$_pgid_file"

    jq --argjson pid "$_pid" '.pid = $pid' "$job_file" > "${job_file}.tmp" \
        && mv "${job_file}.tmp" "$job_file"

    local _waited=0 _max_ticks=$(( timeout * 10 ))
    while kill -0 $_pid 2>/dev/null && (( _waited < _max_ticks )); do
        sleep 0.1
        _waited=$((_waited + 1))
    done
    if kill -0 $_pid 2>/dev/null; then
        _proc_kill "$_pid" TERM; sleep 2
        _proc_kill "$_pid" KILL
        local _sz; _sz=$(wc -c < "$result_file" 2>/dev/null || echo 0)
        _job_update_status "$job_id" "done" \
            ".finished_at = (now | floor) | .result_size = $_sz | .error = \"timed out after ${timeout}s\""
    else
        wait $_pid 2>/dev/null
        local _rc=$? _sz; _sz=$(wc -c < "$result_file" 2>/dev/null || echo 0)
        if [[ $_rc -eq 0 ]]; then
            _job_update_status "$job_id" "done" \
                ".finished_at = (now | floor) | .result_size = $_sz"
        else
            _job_update_status "$job_id" "failed" \
                ".finished_at = (now | floor) | .result_size = $_sz | .error = \"exit code $_rc\""
        fi
    fi
    rm -f "$_pgid_file" 2>/dev/null || true
    log "DEBUG: [BASH] bg_done: job=$job_id"
}

# ============================================================================
# SECTION 7f: Trace System — linear file modification history with undo
# ============================================================================

# ── Globals ──
TRACE_DIR=""
TRACE_DIR_FRAMES=""
TRACE_DIR_OBJECTS=""
TRACE_DIR_SNAPS=""
TRACE_HEAD_FILE=""      # path to HEAD file
TRACE_HEAD=0            # current frame sequence number
BASHAGT_TRACE_DESC=""
BASHAGT_TRACE_TOOL=""

# ── _trace_hash — compute SHA256 of content, returns hash ──
_trace_hash() {
    printf '%s' "$1" | _cc_hash
}

# ── _trace_object_path — get filesystem path for a content hash ──
_trace_object_path() {
    local _hash="$1"
    printf '%s/%s/%s' "$TRACE_DIR_OBJECTS" "${_hash:0:2}" "${_hash:2}"
}

# ── _trace_object_store — store content in objects/, return hash ──
_trace_object_store() {
    local _content="$1"
    local _hash; _hash=$(_trace_hash "$_content")
    local _obj; _obj=$(_trace_object_path "$_hash")
    if [[ ! -f "$_obj" ]]; then
        mkdir -p "$(dirname "$_obj")" 2>/dev/null || true
        printf '%s' "$_content" > "$_obj"
        log "DEBUG: trace_object_store hash=$_hash size=${#_content}" 2>/dev/null || true
    fi
    printf '%s' "$_hash"
}

# ── _trace_object_read — read content from objects/ by hash ──
_trace_object_read() {
    local _hash="$1"
    local _obj; _obj=$(_trace_object_path "$_hash")
    if [[ -f "$_obj" ]]; then
        cat "$_obj"
        return 0
    fi
    log "WARN: trace_object_read miss hash=$_hash" 2>/dev/null || true
    return 1
}

# ── _trace_get_head — read current HEAD ──
_trace_get_head() {
    if [[ -f "$TRACE_HEAD_FILE" ]]; then
        TRACE_HEAD=$(cat "$TRACE_HEAD_FILE" 2>/dev/null || echo 0)
    else
        TRACE_HEAD=0
    fi
}

# ── _trace_set_head — write HEAD value atomically ──
_trace_set_head() {
    local _val="$1"
    printf '%d' "$_val" > "$TRACE_HEAD_FILE.tmp" && mv "$TRACE_HEAD_FILE.tmp" "$TRACE_HEAD_FILE"
    TRACE_HEAD=$_val
}

# ── _trace_inc_head — atomically increment HEAD, return new seq ──
_trace_inc_head() {
    local _seq
    while true; do
        _trace_get_head
        _seq=$((TRACE_HEAD + 1))
        local _lock="$TRACE_DIR_FRAMES/$(printf '%04d' $_seq).lock"
        if mkdir "$_lock" 2>/dev/null; then
            _trace_set_head "$_seq"
            rmdir "$_lock" 2>/dev/null || true
            printf '%d' "$_seq"
            return 0
        fi
        # Another process claimed this seq, retry
        _spin_sleep 0.01
    done
}

# ── trace_init — initialize trace directory structure ──
trace_init() {
    local _base="${BASHAGT_PROJECT_DIR:-.}"
    TRACE_DIR="$_base/.bashagt/trace"
    TRACE_DIR_FRAMES="$TRACE_DIR/frames"
    TRACE_DIR_OBJECTS="$TRACE_DIR/objects"
    TRACE_DIR_SNAPS="$TRACE_DIR/snaps"
    TRACE_HEAD_FILE="$TRACE_DIR/HEAD"
    mkdir -p "$TRACE_DIR_FRAMES" "$TRACE_DIR_OBJECTS" "$TRACE_DIR_SNAPS" 2>/dev/null || true
    _trace_get_head
    # Create genesis frame if no frames exist
    if [[ ! -f "$TRACE_DIR_FRAMES/0000.json" ]]; then
        jq -nc '{seq:0, parent:null, ts:0, hash:"", cause:{agent:"init",tool:"init",turn:0,desc:"genesis frame"}, changes:{}}' > "$TRACE_DIR_FRAMES/0000.json"
        _trace_set_head 0
    fi
}

# ── trace_record — record a file modification ──
# Args: path old_content new_content cause_json
# cause_json: {"agent":"main","tool":"edit_file","turn":42,"desc":"..."}
trace_record() {
    [[ "${BASHAGT_TRACE_ENABLED:-1}" == "1" ]] || return 0
    [[ -n "$TRACE_DIR" ]] || trace_init
    local _path="$1" _old="$2" _new="$3" _cause="$4"
    # Compute hashes and store objects
    local _before_hash="" _after_hash=""
    if [[ -n "$_old" ]]; then
        _before_hash=$(_trace_object_store "$_old")
    fi
    if [[ -n "$_new" ]]; then
        _after_hash=$(_trace_object_store "$_new")
    fi
    # Generate unified diff for display
    local _patch=""
    if [[ -n "$_before_hash" ]]; then
        local _tmp_old _tmp_new
        _tmp_old=$(_mktemp_file 2>/dev/null) || return 0
        _tmp_new=$(_mktemp_file 2>/dev/null) || { rm -f "$_tmp_old"; return 0; }
        printf '%s' "$_old" > "$_tmp_old"
        printf '%s' "$_new" > "$_tmp_new"
        _patch=$(diff -u "$_tmp_old" "$_tmp_new" 2>/dev/null | head -50 || true)
        rm -f "$_tmp_old" "$_tmp_new"
    fi
    # Build changes object
    local _changes; _changes=$(jq -nc --arg path "$_path" --arg before "$_before_hash" \
        --arg after "$_after_hash" --arg patch "$_patch" \
        '{($path): {before:$before, after:$after, patch:$patch}}')
    # Get next frame sequence
    local _seq; _seq=$(_trace_inc_head)
    _trace_get_head  # re-sync: $() runs in subshell, TRACE_HEAD not updated
    local _parent=$((_seq - 1))
    local _ts; _ts=$(_timestamp_ms)
    # Build frame
    local _frame; _frame=$(jq -nc --argjson seq "$_seq" --argjson parent "$_parent" \
        --argjson ts "$_ts" --argjson cause "$_cause" --argjson changes "$_changes" \
        '{seq:$seq, parent:$parent, ts:$ts, cause:$cause, changes:$changes}')
    # Compute frame hash for self-verification
    local _frame_hash; _frame_hash=$(_trace_hash "$_frame")
    _frame=$(jq -nc --argjson frame "$(printf '%s' "$_frame" | jq -c '.')" \
        --arg hash "$_frame_hash" '$frame + {hash:$hash}')
    # Write frame atomically
    local _frame_file; _frame_file=$(printf '%s/%04d.json' "$TRACE_DIR_FRAMES" "$_seq")
    printf '%s\n' "$_frame" > "$_frame_file"
    log "DEBUG: trace_record seq=$_seq path=$_path before_bytes=${#_old} after_bytes=${#_new}" 2>/dev/null || true
    # Auto-snapshot every trace_snapshot_interval frames
    local _interval="${BASHAGT_TRACE_SNAPSHOT_INTERVAL:-50}"
    if (( _seq % _interval == 0 )); then
        trace_snapshot 2>/dev/null &
        _proc_register $! "trace" "snapshot"
    fi
    # Prune if over max
    local _max="${BASHAGT_TRACE_MAX_FRAMES:-1000}"
    if (( _seq > _max + 50 )); then
        trace_prune "$_max" 2>/dev/null &
        _proc_register $! "trace" "prune"
    fi
}

# ── trace_log — display recent N frames ──
trace_log() {
    local _n="${1:-10}" _seq _frame _out=""
    [[ -n "$TRACE_DIR" ]] || trace_init
    _trace_get_head
    _seq=$TRACE_HEAD
    local _count=0
    while (( _seq > 0 && _count < _n )); do
        local _ff; _ff=$(printf '%s/%04d.json' "$TRACE_DIR_FRAMES" "$_seq")
        if [[ -f "$_ff" ]]; then
            _frame=$(cat "$_ff" 2>/dev/null)
            local _ts _agent _tool _desc _paths
            _ts=$(_date_from_epoch "$(printf '%s' "$_frame" | jq -r '.ts // 0' | awk '{printf "%d", $1/1000}')" '%H:%M:%S' 2>/dev/null || echo "??:??:??")
            _agent=$(printf '%s' "$_frame" | jq -r '.cause.agent // "?"')
            _tool=$(printf '%s' "$_frame" | jq -r '.cause.tool // "?"')
            _desc=$(printf '%s' "$_frame" | jq -r '.cause.desc // ""')
            _paths=$(printf '%s' "$_frame" | jq -r '.changes | keys | join(", ")' 2>/dev/null)
            local _label="$_agent:$_tool"
            [[ "$_agent" == "main" ]] && _label="$_tool"
            printf -v _out '%s[%d] %s  %-20s  %s  %s\n' "$_out" "$_seq" "$_ts" "$_label" "$_paths" "$_desc"
        fi
        _seq=$((_seq - 1))
        _count=$((_count + 1))
    done
    if [[ -z "$_out" ]]; then
        printf '%s\n' "  No trace frames recorded."
    else
        printf '%s' "$_out"
    fi
}

# ── trace_show — display a specific frame with diff ──
trace_show() {
    local _seq="$1" _path="${2:-}"
    [[ -n "$TRACE_DIR" ]] || trace_init
    local _ff; _ff=$(printf '%s/%04d.json' "$TRACE_DIR_FRAMES" "$_seq")
    if [[ ! -f "$_ff" ]]; then
        printf 'Frame %d not found.\n' "$_seq"
        return 1
    fi
    local _frame; _frame=$(cat "$_ff")
    local _ts _agent _tool _desc
    _ts=$(_date_from_epoch "$(printf '%s' "$_frame" | jq -r '.ts // 0' | awk '{printf "%d", $1/1000}')" '%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "unknown")
    _agent=$(printf '%s' "$_frame" | jq -r '.cause.agent // "?"')
    _tool=$(printf '%s' "$_frame" | jq -r '.cause.tool // "?"')
    _desc=$(printf '%s' "$_frame" | jq -r '.cause.desc // ""')
    printf 'Frame %d  %s  %s:%s\n' "$_seq" "$_ts" "$_agent" "$_tool"
    printf '  %s\n' "$_desc"
    printf '\n'
    # Show diff for each changed file
    local _files; _files=$(printf '%s' "$_frame" | jq -r '.changes | keys[]' 2>/dev/null)
    while IFS= read -r _f; do
        [[ -z "$_f" ]] && continue
        if [[ -n "$_path" && "$_f" != "$_path" ]]; then continue; fi
        printf '── %s ──\n' "$_f"
        local _patch; _patch=$(printf '%s' "$_frame" | jq -r --arg f "$_f" '.changes[$f].patch // ""' 2>/dev/null)
        if [[ -n "$_patch" ]]; then
            printf '%s\n' "$_patch"
        else
            local _before _after
            _before=$(printf '%s' "$_frame" | jq -r --arg f "$_f" '.changes[$f].before // ""')
            _after=$(printf '%s' "$_frame" | jq -r --arg f "$_f" '.changes[$f].after // ""')
            [[ -z "$_before" ]] && printf '(new file)\n'
            [[ -z "$_after" ]] && printf '(deleted)\n'
            [[ -n "$_before" && -n "$_after" ]] && printf '(no diff available — see objects/)\n'
        fi
        printf '\n'
    done <<< "$_files"
}

# ── trace_diff — show diff between two frames for a file ──
trace_diff() {
    local _seq1="$1" _seq2="$2" _path="$3"
    [[ -n "$TRACE_DIR" ]] || trace_init
    local _ff1; _ff1=$(printf '%s/%04d.json' "$TRACE_DIR_FRAMES" "$_seq1")
    local _ff2; _ff2=$(printf '%s/%04d.json' "$TRACE_DIR_FRAMES" "$_seq2")
    [[ -f "$_ff1" ]] || { printf 'Frame %d not found.\n' "$_seq1"; return 1; }
    [[ -f "$_ff2" ]] || { printf 'Frame %d not found.\n' "$_seq2"; return 1; }
    local _hash1 _hash2
    if [[ -n "$_path" ]]; then
        _hash1=$(jq -r --arg f "$_path" '.changes[$f].after // ""' "$_ff1" 2>/dev/null)
        _hash2=$(jq -r --arg f "$_path" '.changes[$f].after // ""' "$_ff2" 2>/dev/null)
    else
        # Find common files between the two frames
        local _files; _files=$(jq -r '[.changes | keys[]] - [(.[1].changes | keys[])] | .[]' \
            <(jq -s '.' "$_ff1" "$_ff2") 2>/dev/null)
        [[ -z "$_files" ]] && { printf 'No common files between frames %d and %d.\n' "$_seq1" "$_seq2"; return 0; }
        _path=$(printf '%s' "$_files" | head -1)
        _hash1=$(jq -r --arg f "$_path" '.changes[$f].after // ""' "$_ff1" 2>/dev/null)
        _hash2=$(jq -r --arg f "$_path" '.changes[$f].after // ""' "$_ff2" 2>/dev/null)
    fi
    [[ -z "$_hash1" || "$_hash1" == "null" ]] && { printf 'File %s not found in frame %d.\n' "$_path" "$_seq1"; return 1; }
    [[ -z "$_hash2" || "$_hash2" == "null" ]] && { printf 'File %s not found in frame %d.\n' "$_path" "$_seq2"; return 1; }
    local _tmp1 _tmp2
    _tmp1=$(_mktemp_file) || return 1
    _tmp2=$(_mktemp_file) || { rm -f "$_tmp1"; return 1; }
    _trace_object_read "$_hash1" > "$_tmp1"
    _trace_object_read "$_hash2" > "$_tmp2"
    diff -u -L "a/$_path@$_seq1" -L "b/$_path@$_seq2" "$_tmp1" "$_tmp2" 2>/dev/null || true
    rm -f "$_tmp1" "$_tmp2"
}

# ── trace_snapshot — create full snapshot at current HEAD ──
trace_snapshot() {
    [[ -n "$TRACE_DIR" ]] || trace_init
    log "DEBUG: trace_snapshot seq=$TRACE_HEAD" 2>/dev/null || true
    _trace_get_head
    local _seq=$TRACE_HEAD
    local _snap_dir; _snap_dir=$(printf '%s/%04d' "$TRACE_DIR_SNAPS" "$_seq")
    mkdir -p "$_snap_dir" 2>/dev/null || return 0
    # Walk backward from HEAD, collecting latest state of each file
    local _manifest="{}" _s=$_seq _path _after_hash
    declare -A _seen
    while (( _s > 0 )); do
        local _ff; _ff=$(printf '%s/%04d.json' "$TRACE_DIR_FRAMES" "$_s")
        [[ -f "$_ff" ]] || { _s=$((_s - 1)); continue; }
        local _files; _files=$(jq -r '.changes | keys[]' "$_ff" 2>/dev/null)
        while IFS= read -r _path; do
            [[ -z "$_path" ]] && continue
            [[ -n "${_seen[$_path]:-}" ]] && continue
            _seen[$_path]=1
            _after_hash=$(jq -r --arg f "$_path" '.changes[$f].after // ""' "$_ff" 2>/dev/null)
            [[ -z "$_after_hash" || "$_after_hash" == "null" ]] && continue
            _manifest=$(jq -nc --argjson m "$_manifest" --arg path "$_path" --arg hash "$_after_hash" \
                '$m + {($path): $hash}')
        done <<< "$_files"
        _s=$((_s - 1))
    done
    # Write manifest
    printf '%s' "$_manifest" > "$_snap_dir/manifest.json"
}

# ── trace_undo — undo N frames from HEAD (stack pop) ──
# Returns: 0 on success, 1 on verification failure or cancel
trace_undo() {
    local _n="${1:-1}" _mode="${2:-}"
    local _skip_confirm=0 _skip_verify=0
    case "$_mode" in
        force)  _skip_confirm=1; _skip_verify=1 ;;
        silent) _skip_confirm=1 ;;
    esac
    [[ -n "$TRACE_DIR" ]] || trace_init
    _trace_get_head
    local _current=$TRACE_HEAD
    (( _current > 0 )) || { printf 'Nothing to undo (HEAD at genesis).\n'; return 1; }
    (( _n > _current )) && _n=$_current
    # Phase 1: Read frames and verify hashes
    local _s _ff _frame _seq_list=""
    for (( _s = _current; _s > _current - _n; _s-- )); do
        _ff=$(printf '%s/%04d.json' "$TRACE_DIR_FRAMES" "$_s")
        [[ -f "$_ff" ]] || { printf 'Frame %d not found — aborting.\n' "$_s"; return 1; }
        _frame=$(cat "$_ff")
        _seq_list="$_seq_list $_s"
    done
    # Phase 2: Preview
    printf 'Will undo %d frame(s):\n' "$_n"
    for _s in $_seq_list; do
        _ff=$(printf '%s/%04d.json' "$TRACE_DIR_FRAMES" "$_s")
        _frame=$(cat "$_ff")
        local _paths; _paths=$(printf '%s' "$_frame" | jq -r '.changes | keys | join(", ")' 2>/dev/null)
        local _desc; _desc=$(printf '%s' "$_frame" | jq -r '.cause.desc // ""')
        printf '  [%d] %s — %s\n' "$_s" "$_paths" "$_desc"
    done
    # Phase 3: Verify (skip if force/skip_verify)
    local _failed=0
    if (( ! _skip_verify )); then
        for _s in $_seq_list; do
            _ff=$(printf '%s/%04d.json' "$TRACE_DIR_FRAMES" "$_s")
            _frame=$(cat "$_ff")
            local _files; _files=$(printf '%s' "$_frame" | jq -r '.changes | keys[]' 2>/dev/null)
            while IFS= read -r _f; do
                [[ -z "$_f" ]] && continue
                local _after_hash; _after_hash=$(printf '%s' "$_frame" | jq -r --arg f "$_f" '.changes[$f].after // ""')
                [[ -z "$_after_hash" || "$_after_hash" == "null" ]] && continue
                if [[ ! -f "$_f" ]]; then
                    printf '  File %s no longer exists — verification failed.\n' "$_f"
                    _failed=1; break
                fi
                local _current_hash; _current_hash=$(_trace_hash "$(cat "$_f")")
                if [[ "$_current_hash" != "$_after_hash" ]]; then
                    printf '  File %s was modified outside bashagt — verification failed.\n' "$_f"
                    printf '  Use /undo --force to discard external changes.\n'
                    _failed=1; break
                fi
            done <<< "$_files"
            (( _failed )) && break
        done
    fi
    if (( _failed )); then
        printf 'Undo aborted.\n'
        return 1
    fi
    # Phase 4: Execute — restore before content from each frame
    if (( _skip_confirm )); then
        printf '%s\n' "$( (( _skip_verify )) && printf '(force) ' || true)Restoring..."
    else
        printf '%s\n' "$( (( _skip_verify )) && printf '(force) ' || true)Proceed? [y/N] "
        read -r _confirm
        [[ "$_confirm" == "y" || "$_confirm" == "Y" ]] || { printf 'Cancelled.\n'; return 1; }
    fi
    local _restored=""
    for _s in $_seq_list; do
        _ff=$(printf '%s/%04d.json' "$TRACE_DIR_FRAMES" "$_s")
        _frame=$(cat "$_ff")
        local _files; _files=$(printf '%s' "$_frame" | jq -r '.changes | keys[]' 2>/dev/null)
        while IFS= read -r _f; do
            [[ -z "$_f" ]] && continue
            local _before_hash; _before_hash=$(printf '%s' "$_frame" | jq -r --arg f "$_f" '.changes[$f].before // ""')
            if [[ -z "$_before_hash" || "$_before_hash" == "null" ]]; then
                rm -f "$_f" 2>/dev/null || true
                _restored+="$_f (deleted) "
            else
                # Detect directory manifest: JSON object where values are hashes or "_dir_"
                local _raw; _raw=$(_trace_object_read "$_before_hash" 2>/dev/null || true)
                if [[ "$_raw" == '{'* ]] && jq -e 'type == "object" and ([.[] | select(test("^[a-f0-9]{64}$") or . == "_dir_")] | length) > 0' <<< "$_raw" >/dev/null 2>&1; then
                    # Directory manifest — restore tree
                    rm -rf "$_f" 2>/dev/null || true
                    mkdir -p "$_f" 2>/dev/null || true
                    jq -r 'to_entries[] | "\(.key)\t\(.value)"' <<< "$_raw" 2>/dev/null | \
                    while IFS=$'\t' read -r _rp _rh; do
                        [[ -z "$_rp" ]] && continue
                        if [[ "$_rh" == "_dir_" ]]; then
                            mkdir -p "$_f/$_rp" 2>/dev/null || true
                        else
                            mkdir -p "$(dirname "$_f/$_rp")" 2>/dev/null || true
                            _trace_object_read "$_rh" > "$_f/$_rp" 2>/dev/null || true
                        fi
                    done
                    _restored+="$_f/ "
                else
                    _trace_object_read "$_before_hash" > "$_f" 2>/dev/null || {
                        printf '  Failed to restore %s from objects/\n' "$_f"
                    }
                    _restored+="$_f "
                fi
            fi
        done <<< "$_files"
    done
    # Phase 5: Move HEAD back
    local _new_head=$((_current - _n))
    _trace_set_head "$_new_head"
    printf 'Undone %d frame(s): %s\n' "$_n" "${_restored% }"
    log "INFO: trace_undo frames=$_n result=ok restored=${_restored% }" 2>/dev/null || true
    return 0
}

# ── trace_prune — archive old frames into snapshots, clean orphans ──
trace_prune() {
    local _keep="${1:-200}"
    [[ -n "$TRACE_DIR" ]] || trace_init
    log "DEBUG: trace_prune keep=$_keep current_head=$TRACE_HEAD" 2>/dev/null || true
    _trace_get_head
    local _cutoff=$((TRACE_HEAD - _keep))
    (( _cutoff <= 1 )) && return 0
    # Create snapshot at cutoff
    local _snap_dir; _snap_dir=$(printf '%s/%04d' "$TRACE_DIR_SNAPS" "$_cutoff")
    mkdir -p "$_snap_dir" 2>/dev/null || true
    # Remove frames older than cutoff
    local _s
    for (( _s = 1; _s < _cutoff; _s++ )); do
        local _ff; _ff=$(printf '%s/%04d.json' "$TRACE_DIR_FRAMES" "$_s")
        rm -f "$_ff" 2>/dev/null || true
    done
}

# ── _slash_trace — /trace command handler ──
_slash_trace() {
    local _trimmed="$1" _sub="${_trimmed#/trace }"
    [[ "$_sub" == "$_trimmed" ]] && _sub="log"
    case "$_sub" in
        log|log\ *)
            local _n="${_sub#log }"
            [[ "$_n" == "log" ]] && _n="10"
            local _out; _out=$(trace_log "$_n" 2>/dev/null)
            _stream_emit "text" "$(jq -nc --arg c "$_out" '{content: $c}')" ;;
        show\ *)
            local _args="${_sub#show }" _seq _path
            _seq="${_args%% *}"
            _path="${_args#* }"
            [[ "$_path" == "$_seq" ]] && _path=""
            local _out; _out=$(trace_show "$_seq" "$_path" 2>/dev/null)
            _stream_emit "text" "$(jq -nc --arg c "$_out" '{content: $c}')" ;;
        diff\ *)
            local _a _b _p
            _a=$(echo "${_sub#diff }" | awk '{print $1}')
            _b=$(echo "${_sub#diff }" | awk '{print $2}')
            _p=$(echo "${_sub#diff }" | awk '{print $3}')
            local _out; _out=$(trace_diff "$_a" "$_b" "$_p" 2>/dev/null)
            _stream_emit "text" "$(jq -nc --arg c "$_out" '{content: $c}')" ;;
        status)
            [[ -n "$TRACE_DIR" ]] || trace_init
            _trace_get_head
            local _frames _objs _snaps _size
            _frames=$(ls "$TRACE_DIR_FRAMES"/*.json 2>/dev/null | wc -l)
            _objs=$(find "$TRACE_DIR_OBJECTS" -type f 2>/dev/null | wc -l)
            _snaps=$(ls -d "$TRACE_DIR_SNAPS"/*/ 2>/dev/null | wc -l)
            _size=$(du -sh "$TRACE_DIR" 2>/dev/null | awk '{print $1}')
            local _out; printf -v _out 'Trace status:\n  HEAD: %d\n  Frames: %d\n  Objects: %d\n  Snapshots: %d\n  Disk: %s\n' \
                "$TRACE_HEAD" "$_frames" "$_objs" "$_snaps" "${_size:-?}"
            _stream_emit "text" "$(jq -nc --arg c "$_out" '{content: $c}')" ;;
        snapshot)
            trace_snapshot 2>/dev/null &
            _stream_emit "text" "$(jq -nc --arg c '  Snapshot triggered in background.' '{content: $c}')" ;;
        *)
            _stream_emit "text" "$(jq -nc --arg c '  /trace [log|show|diff|status|snapshot]' '{content: $c}')" ;;
    esac
}

# ── _slash_undo — /undo command handler ──
_slash_undo() {
    local _trimmed="$1" _args="${_trimmed#/undo }"
    [[ "$_args" == "$_trimmed" ]] && _args="1"
    local _n=1 _force=""
    case "$_args" in
        --force\ *) _force="force"; _n="${_args#--force }"; [[ "$_n" == "--force" || -z "$_n" ]] && _n=1 ;;
        --force) _force="force"; _n=1 ;;
        *) _n="${_args%% *}" ;;
    esac
    [[ "$_n" =~ ^[0-9]+$ ]] || { _stream_emit "text" "$(jq -nc --arg c '  Usage: /undo [N] [--force]' '{content: $c}')"; return; }
    local _out; _out=$(trace_undo "$_n" "$_force" 2>/dev/null) || true
    _stream_emit "text" "$(jq -nc --arg c "$_out" '{content: $c}')"
    # Inject undo result into context so model knows
    if [[ "$_out" == *"Undone"* ]]; then
        _HOOK_CONTEXT_BUFFER+=$'\n'"$_out"$'\n'
    fi
}

# ── _trace_undo_for_model — called from undo tool dispatch ──
# Wraps trace_undo with request() for human approval atomicity
# ── _trace_undo_exec — just trace_undo (wrapped by async_spin after request) ──
_trace_undo_exec() {
    local _n="$1" _mode="$2"
    trace_undo "$_n" "$_mode" 2>/dev/null
}

# ── _trace_undo_for_model — request → spinner → undo, called from undo tool dispatch ──
_trace_undo_for_model() {
    local _n="${1:-1}" _reason="${2:-}" _desc="${3:-}" _force="${4:-false}"
    [[ -n "$TRACE_DIR" ]] || trace_init
    # Build preview
    _trace_get_head
    local _preview=""; _preview=$(printf 'Model requests undo of %d frame(s).\n' "$_n")
    [[ -n "$_desc" ]] && _preview+=$(printf '  %s\n' "$_desc")
    _preview+=$'\n'
    local _s _ff _frame
    for (( _s = TRACE_HEAD; _s > TRACE_HEAD - _n && _s > 0; _s-- )); do
        _ff=$(printf '%s/%04d.json' "$TRACE_DIR_FRAMES" "$_s")
        [[ -f "$_ff" ]] || continue
        _frame=$(cat "$_ff")
        local _paths _desc2
        _paths=$(printf '%s' "$_frame" | jq -r '.changes | keys | join(", ")' 2>/dev/null)
        _desc2=$(printf '%s' "$_frame" | jq -r '.cause.desc // ""')
        _preview+=$(printf '  [%d] %s — %s\n' "$_s" "$_paths" "$_desc2")
    done
    [[ -n "$_reason" ]] && _preview+=$'\n'"Reason: $_reason"$'\n'
    [[ "$_force" == "true" ]] && _preview+=$'\n'"⚠  Force mode: hash verification will be skipped."$'\n'
    # Phase 1: Request approval (blocking TUI, no spinner)
    local _opts='["approve","deny"]'
    local _ctx="undo_request"
    local _response; _response=$(tool_request "$_preview" "$_opts" "$_ctx" 2>/dev/null)
    if [[ "$_response" == *'"choice":"approve"'* ]]; then
        local _mode="silent"  # always skip confirm (request already approved)
        [[ "$_force" == "true" ]] && _mode="force"  # also skip hash verification
        # Phase 2: Execute undo with spinner
        local _out_file; _out_file=$(_mktemp_file 2>/dev/null)
        if [[ -n "$_out_file" ]]; then
            async_spin --dot --desc "$_desc" "Undoing" "done" "$_out_file" \
                _trace_undo_exec "$_n" "$_mode"
            local _result; _result=$(cat "$_out_file" 2>/dev/null || true)
            rm -f "$_out_file"
            printf '%s' "$_result"
            if [[ "$_result" == *"Undone"* ]]; then
                _HOOK_CONTEXT_BUFFER+=$'\n'"$_result"$'\n'
            fi
        else
            local _out; _out=$(trace_undo "$_n" "$_mode" 2>/dev/null)
            printf '%s' "$_out"
            _HOOK_CONTEXT_BUFFER+=$'\n'"$_out"$'\n'
        fi
    else
        printf 'Undo request denied.'
    fi
}

# ============================================================================
# SECTION 8: Tool Definitions
# ============================================================================

TOOL_READ_FILE_SCHEMA=$(cat <<'EOF'
{"name":"read_file","description":"Read a file with line numbers. Supports pagination via offset and limit.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."},"path":{"type":"string","description":"Absolute path to the file"},"offset":{"type":"integer","description":"Line number to start from, 1-indexed"},"limit":{"type":"integer","description":"Maximum lines to return"}},"required":["path","description"]}}
EOF
)

TOOL_WRITE_FILE_SCHEMA=$(cat <<'EOF'
{"name":"write_file","description":"Create a NEW file. REFUSES to overwrite existing files — use edit_file for modifications. Creates parent directories as needed.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."},"path":{"type":"string","description":"Absolute path for the NEW file"},"content":{"type":"string","description":"Content to write to the new file"}},"required":["path","content","description"]}}
EOF
)

TOOL_EDIT_FILE_SCHEMA=$(cat <<'EOF'
{"name":"edit_file","description":"Replace the first occurrence of old_string with new_string in a file. Shows a diff preview before applying. The old_string must be unique in the file. old_string MUST match byte-for-byte — copy the exact lines from read_file output, including whitespace and special characters. If the match fails, re-read the file and try again. This is the ONLY way to modify existing files.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."},"path":{"type":"string","description":"Absolute path to the file"},"old_string":{"type":"string","description":"Literal string to find and replace — must be byte-exact copy from file"},"new_string":{"type":"string","description":"Replacement string"}},"required":["path","old_string","new_string","description"]}}
EOF
)

TOOL_DELETE_FILE_SCHEMA=$(cat <<'EOF'
{"name":"delete_file","description":"Delete a file or directory. Traces content before deletion so undo can restore it. For directories, recursive=true is REQUIRED as a safety gate against accidental recursive deletion. NEVER use bash rm/rmdir — they bypass trace and make recovery impossible.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific description (≤80 chars) of what is being deleted and why. Include what the file/dir contains and why deletion is the right action."},"path":{"type":"string","description":"Absolute path to the file or directory to delete"},"recursive":{"type":"boolean","description":"REQUIRED to delete directories — safety gate. Set to true only when intentionally deleting a directory and all its contents. Ignored for files."}},"required":["description","path"]}}
EOF
)

TOOL_BASH_SCHEMA=$(cat <<'EOF'
{"name":"bash","description":"Execute a bash command in a subprocess. Commands have a timeout and run in an isolated shell. STRICT CONSTRAINT: bash is LAST-RESORT only. Built-in tools MUST be used for their dedicated purposes: read_file (NOT cat/head/tail/less), list_files (NOT ls/find/tree/dir), edit_file (NOT sed/awk/perl -i), write_file (NOT echo >/cat <</tee), delete_file (NOT rm/rmdir). Use bash ONLY when no built-in tool can do the job (e.g., grep for code search, running tests, git commands, package managers). NEVER use cat, head, tail, less, ls, find, tree, sed, awk, tee, dd, cp, mv, rm, rmdir, echo >/>>, cat >/>> on project files.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this command does. Include files, operations, and expected outcomes — not a vague summary. Shown in UI."},"command":{"type":"string","description":"The bash command to execute"},"timeout":{"type":"integer","description":"Max execution time in seconds. Default 120. Range 1-600."},"background":{"type":"boolean","description":"If true, return immediately with a job_id. Use job_poll/job_result to manage. Default false."}},"required":["command","description"]}}
EOF
)

TOOL_LIST_FILES_SCHEMA=$(cat <<'EOF'
{"name":"list_files","description":"List files and directories at the given path.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."},"path":{"type":"string","description":"Directory path to list"}},"required":["path","description"]}}
EOF
)

TOOL_WEB_SEARCH_SCHEMA=$(cat <<'EOF'
{"name":"web_search","description":"Search the web or fetch a URL. Use for current information, documentation, or external resources.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."},"query":{"type":"string","description":"Search query or full URL (https://...) to fetch"},"max_results":{"type":"integer","description":"Max results to return, default 3"}},"required":["query","description"]}}
EOF
)

TOOL_TASK_CREATE_SCHEMA=$(cat <<'EOF'
{"name":"task_create","description":"Create a new TODO task for tracking implementation progress. Use to create actionable items from plans or break down complex work.","input_schema":{"type":"object","properties":{"subject":{"type":"string","description":"Brief task title (one line)"},"description":{"type":"string","description":"Specific, detailed task description (≤80 chars). Include what to do and expected outcome — not a vague summary. Shown in UI."}},"required":["subject","description"]}}
EOF
)

TOOL_MAKE_TODOS_SCHEMA=$(cat <<'EOF'
{"name":"make_todos","description":"Create TODO items from a plan document (NOT for single ad-hoc tasks). Extracts implementation steps using the plan_extractor agent, then creates pending TODOs with source:'plan'. After creation, use task_update to mark progress (in_progress/completed). Only call this when you have a full plan with STEPS section — it costs an LLM call. For status changes, always use task_update.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."},"plan_text":{"type":"string","description":"Full plan document text (with STEPS section etc.)"}},"required":["plan_text","description"]}}
EOF
)

TOOL_TASK_UPDATE_SCHEMA=$(cat <<'EOF'
{"name":"task_update","description":"Update a TODO task status. Mark as in_progress when starting work, completed when done. Only ONE task in_progress at a time. Use this for ALL status changes — make_todos only creates, task_update manages lifecycle.","input_schema":{"type":"object","properties":{"id":{"type":"string","description":"Task ID from task_list output or CURRENT line above"},"status":{"type":"string","description":"New status: pending, in_progress, or completed"},"subject":{"type":"string","description":"New title (optional)"},"description":{"type":"string","description":"Specific description (≤80 chars) of this status change. Include what changed and why — not a vague summary. Shown in UI."}},"required":["id","description"]}}
EOF
)

TOOL_TASK_LIST_SCHEMA=$(cat <<'EOF'
{"name":"task_list","description":"List all TODO tasks with their IDs and statuses. Use to discover task IDs before calling task_update.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."}},"required":["description"]}}
EOF
)
TOOL_SEND_MESSAGE_SCHEMA=$(cat <<'EOF'
{"name":"send_message","description":"Send a text message to another sub-agent or broadcast to all agents. Use for inter-agent coordination.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."},"to":{"type":"string","description":"Target agent name, or \"broadcast\" for all agents"},"content":{"type":"string","description":"Message content"}},"required":["to","content","description"]}}
EOF
)
TOOL_CHECK_MESSAGES_SCHEMA=$(cat <<'EOF'
{"name":"check_messages","description":"Check for new messages sent to this agent. Returns all unread messages as JSONL and clears the inbox.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."}},"required":["description"]}}
EOF
)

TOOL_AGENT_STATUS_SCHEMA=$(cat <<'EOF'
{"name":"agent_status","description":"Get the current status of all available sub-agents. Returns name, description, and status (idle/busy/error) for each.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."}},"required":["description"]}}
EOF
)

TOOL_JOB_POLL_SCHEMA=$(cat <<'EOF'
{"name":"job_poll","description":"Check the status of an async sub-agent job. Returns queued, running, done, failed, or cancelled.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."},"job_id":{"type":"string","description":"Job ID returned by agent tool with async:true"}},"required":["job_id","description"]}}
EOF
)

TOOL_JOB_RESULT_SCHEMA=$(cat <<'EOF'
{"name":"job_result","description":"Get the result of a completed async sub-agent job. Blocks until the job completes or times out.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."},"job_id":{"type":"string","description":"Job ID returned by agent tool with async:true"},"timeout":{"type":"integer","description":"Max seconds to wait, default 30"}},"required":["job_id","description"]}}
EOF
)


TOOL_JOB_CANCEL_SCHEMA=$(cat <<'EOF'
{"name":"job_cancel","description":"Cancel a queued or running async sub-agent job. Running jobs are forcefully terminated.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."},"job_id":{"type":"string","description":"Job ID to cancel"}},"required":["job_id","description"]}}
EOF
)

TOOL_AGENT_BATCH_SCHEMA=$(cat <<'EOF'
{"name":"agent_batch","description":"Execute up to 4 sub-agent tasks in parallel and return all results once every task completes. Each task runs as an independent sub-agent with the same freedom as a direct agent() call. Blocks until all finish.","input_schema":{"type":"object","properties":{"tasks":{"type":"array","minItems":1,"maxItems":4,"items":{"type":"object","properties":{"agent":{"type":"string","description":"Sub-agent name"},"description":{"type":"string","description":"One-line task summary (≤80 chars), shown in the UI spinner. Be specific about target, scope, and expected output. Example: \"Find all callers of handle_auth in src/\" not \"Explore the code\"."},"prompt":{"type":"string","description":"Detailed task brief for the sub-agent (≤2000 chars). Three parts: (1) Provide necessary compressed context — paste relevant code snippets, file paths, errors, findings, etc. Give only the minimum context needed, avoiding information overload and wasted exploration turns. (2) Instructions — what to do, what to find, what output format. (3) Completion criteria — when the task is considered done."}},"required":["agent","prompt","description"]}}},"required":["tasks"]}}
EOF
)

TOOL_REQUEST_SCHEMA=$(cat <<'EOF'
{"name":"request","description":"Request human input for confirmation, permissions, choices, or clarifications. Use ONLY at end of turn when you need human oversight. Do NOT combine with other tool calls in the same response — request must be the ONLY tool_use block.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."},"prompt":{"type":"string","description":"The question to display to the user (max 80 chars, be concise)","maxLength":80},"options":{"type":"array","items":{"type":"string"},"description":"Options to present (2-9 items, each max 40 chars)"},"context":{"type":"string","description":"Additional context shown below the prompt in dim text (max 120 chars)","maxLength":120}},"required":["prompt","options","description"]}}
EOF
)

TOOL_SKILL_SCHEMA=$(cat <<'EOF'
{"name":"skill","description":"Invoke a named skill to apply its specialized workflow to a task. Skills encode reusable domain expertise and step-by-step procedures. Call list_skills first to see what skills are available, then invoke the best match with a clear task description.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."},"name":{"type":"string","description":"Skill name to invoke (e.g. 'brainstorming', 'plan')"},"task":{"type":"string","description":"What to accomplish with this skill — passed to the skill as its goal"}},"required":["name","task","description"]}}
EOF
)

TOOL_LIST_SKILLS_SCHEMA=$(cat <<'EOF'
{"name":"list_skills","description":"List all available skills with names, descriptions, and active status. Call this to discover what skills exist before invoking one with the skill() tool. Active skills (marked [active]) are those the user has explicitly enabled — prefer them.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."}},"required":["description"]}}
EOF
)

TOOL_LIST_AGENTS_SCHEMA=$(cat <<'EOF'
{"name":"list_agents","description":"List all available sub-agents with names, descriptions, and capabilities. Sub-agents are specialized agents with their own tools — use agent(name, prompt) to delegate work to one. Call this to find the right agent before invoking it.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."}},"required":["description"]}}
EOF
)

TOOL_LIST_MCP_TOOLS_SCHEMA=$(cat <<'EOF'
{"name":"list_mcp_tools","description":"List all MCP (Model Context Protocol) tools available from connected external servers, grouped by server. These provide access to databases, APIs, and third-party services. Call this to discover external capabilities before writing custom code for the same task. Invoke discovered tools via mcp__<server>__<tool>(...).","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific, detailed description (≤80 chars) of what this action does. Include filenames, targets, or expected outcomes — not a vague summary. Shown in UI."}},"required":["description"]}}
EOF
)

TOOL_UNDO_SCHEMA=$(cat <<'EOF'
{"name":"undo","description":"LAST-RESORT: request to undo recent file modifications by popping the trace stack. Requires human approval. Use ONLY when reverting entire previous edits is the right fix — not for small corrections (use edit_file). LIFO: only most recent frames can be undone.","input_schema":{"type":"object","properties":{"description":{"type":"string","description":"Specific description (≤80 chars) of what frames to undo and why reverting (not forward correction) is necessary. Shown in request prompt."},"frames":{"type":"integer","description":"Number of frames to undo (default 1). LIFO — only the most recent frames can be popped."},"force":{"type":"boolean","description":"Skip hash verification when files may have been externally modified (default false). Human approval still required."},"reason":{"type":"string","description":"Why undo is the right fix — what went wrong, why edit_file cannot correct it, and why reverting entire frame(s) is justified"}},"required":["description","reason"]}}
EOF
)

# Build combined tools JSON once
# Tools JSON cache (optimization #2: avoid rebuilding every API call)
TOOLS_JSON_CACHE=""
TOOLS_CACHE_EPOCH=0
# System prompt JSON cache — invalidated when BASHAGT.md or skills change
SYS_JSON_CACHE=""

invalidate_tools_cache() {
    TOOLS_JSON_CACHE=""
    TOOLS_CACHE_EPOCH=0
}

build_tools_json() {
    local _epoch; _epoch=${EPOCHSECONDS:-$(date +%s)}
    # Cache valid for 5 minutes, invalidated by agent_manager changes
    if [[ -n "$TOOLS_JSON_CACHE" ]] && (( _epoch - TOOLS_CACHE_EPOCH < 300 )); then
        printf '%s' "$TOOLS_JSON_CACHE"
        return
    fi

    local agent_schema; agent_schema=$(build_agent_schema)
    local mcp_tools; mcp_tools=$(mcp_build_tools_json 2>/dev/null || true)
    TOOLS_JSON_CACHE=$(printf '%s\n' \
        "$TOOL_READ_FILE_SCHEMA" \
        "$TOOL_WRITE_FILE_SCHEMA" \
        "$TOOL_EDIT_FILE_SCHEMA" \
        "$TOOL_DELETE_FILE_SCHEMA" \
        "$TOOL_BASH_SCHEMA" \
        "$TOOL_LIST_FILES_SCHEMA" \
        "$TOOL_WEB_SEARCH_SCHEMA" \
        "$TOOL_MAKE_TODOS_SCHEMA" \
        "$TOOL_TASK_UPDATE_SCHEMA" \
        "$TOOL_TASK_LIST_SCHEMA" \
        "$agent_schema" \
        "$TOOL_AGENT_STATUS_SCHEMA" \
        "$TOOL_REQUEST_SCHEMA" \
        "$TOOL_SKILL_SCHEMA" \
        "$TOOL_LIST_SKILLS_SCHEMA" \
        "$TOOL_LIST_AGENTS_SCHEMA" \
        "$TOOL_LIST_MCP_TOOLS_SCHEMA" \
        "$TOOL_JOB_POLL_SCHEMA" \
        "$TOOL_JOB_RESULT_SCHEMA" \
        "$TOOL_JOB_CANCEL_SCHEMA" \
        "$TOOL_AGENT_BATCH_SCHEMA" \
        "$TOOL_UNDO_SCHEMA" \
        "$mcp_tools" | jq -s '.')
    TOOLS_CACHE_EPOCH=$_epoch
    printf '%s' "$TOOLS_JSON_CACHE"
}

# ============================================================================
# SECTION 9: Tool Implementations
# ============================================================================

tool_read_file() {
    local path="$1" offset="${2:-1}" limit="${3:-500}"
    if [[ -z "$path" ]]; then
        printf 'ERROR: path is required (empty string received). read_file needs an absolute file path.\n'
        return 1
    fi
    if [[ ! -f "$path" ]]; then
        printf 'Error: file not found: %s\n' "$path"
        return 1
    fi
    if [[ ! -r "$path" ]]; then
        printf 'Error: file not readable: %s\n' "$path"
        return 1
    fi
    local _fsize; _fsize=$(wc -c < "$path" 2>/dev/null || echo 0)

    # Check for binary (null bytes)
    if grep -qa $'\x00' "$path" 2>/dev/null; then
        printf 'Warning: file appears to be binary; output may be garbled\n'
        printf '%s\n' '---'
    fi
    tail -n +"$offset" "$path" 2>/dev/null | head -n "$limit" | nl -ba -w4 -s' '
}

tool_write_file() {
    local path="$1" content="$2"
    if [[ -z "$path" ]]; then
        printf 'ERROR: path is required (empty string received). write_file needs an absolute file path.\n'
        return 1
    fi
    # Refuse to overwrite existing files — use edit_file for modifications
    if [[ -f "$path" ]]; then
        printf 'ERROR: %s already exists. Use read_file + edit_file to modify it.\n' "$path"
        printf 'write_file is ONLY for creating NEW files, not modifying existing ones.\n'
        return 1
    fi
    local dir
    dir=$(dirname "$path")
    if [[ ! -d "$dir" ]]; then
        mkdir -p "$dir" 2>/dev/null || {
            printf 'Error: cannot create directory: %s\n' "$dir"
            return 1
        }
    fi
    { printf '%s' "$content" > "$path"; } 2>/dev/null || {
        printf 'Error: cannot write to: %s\n' "$path"
        return 1
    }
    # Trace: record new file creation
    if [[ "${BASHAGT_TRACE_ENABLED:-1}" == "1" ]]; then
        trace_record "$path" "" "$content" \
            "$(jq -nc --arg agent "${AGENT_SELF_NAME:-main}" --arg tool "write_file" \
               --arg desc "${BASHAGT_TRACE_DESC:-}" --argjson turn "${TURN_COUNT:-0}" \
               '{agent:$agent, tool:$tool, turn:$turn, desc:$desc}')" 2>/dev/null || true
    fi
    # Content preview (first 23 lines, + prefix for green diff highlighting)
    local _w_preview _w_total
    _w_preview=$(printf '%s' "$content" | head -23 | awk '{printf "%d +%s\n", NR, $0}' 2>/dev/null)
    printf '%s\n' "$_w_preview"
    _w_total=$(printf '%s' "$content" | awk 'END{print NR}')
    if (( _w_total > 23 )); then
        printf '...(additional new lines)\n'
    fi
    printf 'File written successfully: %s\n' "$path"
}

tool_delete_file() {
    local path="$1" recursive="${2:-false}"
    if [[ -z "$path" ]]; then
        printf 'ERROR: path is required (empty string received). delete_file needs an absolute path.\n'
        return 1
    fi
    if [[ ! -e "$path" ]]; then
        printf 'Error: path not found: %s\n' "$path"
        return 1
    fi
    # Directory safety gate
    if [[ -d "$path" ]] && [[ "$recursive" != "true" ]]; then
        printf 'ERROR: %s is a directory. Use recursive=true to delete directories and their contents.\n' "$path"
        return 1
    fi
    # Collect content for trace before deletion
    local _old_content="" _file_count=0 _total_bytes=0
    if [[ -f "$path" ]]; then
        _old_content=$(cat "$path" 2>/dev/null || true)
        _total_bytes=${#_old_content}
        _file_count=1
    elif [[ -d "$path" ]]; then
        # Build directory manifest: {relative_path: hash}
        local _manifest="{}"
        while IFS= read -r -d '' _f; do
            local _rel="${_f#$path/}"
            local _fcontent; _fcontent=$(cat "$_f" 2>/dev/null || true)
            local _fh; _fh=$(_trace_object_store "$_fcontent")
            _manifest=$(jq -nc --argjson m "$_manifest" --arg r "$_rel" --arg h "$_fh" \
                '$m + {($r): $h}')
            _file_count=$((_file_count + 1))
            _total_bytes=$((_total_bytes + ${#_fcontent}))
        done < <(find "$path" -type f -print0 2>/dev/null || true)
        # Check for empty subdirectories
        while IFS= read -r -d '' _d; do
            local _drel="${_d#$path/}"
            local _has_files; _has_files=$(find "$_d" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null || true)
            if [[ -z "$_has_files" ]]; then
                _manifest=$(jq -nc --argjson m "$_manifest" --arg r "$_drel" \
                    '$m + {($r): "_dir_"}')
            fi
        done < <(find "$path" -type d -print0 2>/dev/null | tail -n +2 || true)
        _old_content=$(printf '%s' "$_manifest")
    fi
    # Trace before deletion
    if [[ "${BASHAGT_TRACE_ENABLED:-1}" == "1" ]]; then
        trace_record "$path" "$_old_content" "" \
            "$(jq -nc --arg agent "${AGENT_SELF_NAME:-main}" --arg tool "delete_file" \
               --arg desc "${BASHAGT_TRACE_DESC:-}" --argjson turn "${TURN_COUNT:-0}" \
               '{agent:$agent, tool:$tool, turn:$turn, desc:$desc}')" 2>/dev/null || true
    fi
    # Execute deletion
    if [[ -f "$path" ]]; then
        rm -f "$path" 2>/dev/null || { printf 'Error: failed to delete %s\n' "$path"; return 1; }
    else
        rm -rf "$path" 2>/dev/null || { printf 'Error: failed to delete %s\n' "$path"; return 1; }
    fi
    if [[ -e "$path" ]]; then
        printf 'Error: deletion failed — %s still exists\n' "$path"
        return 1
    fi
    # Output
    if (( _file_count == 1 )); then
        printf 'Deleted: %s (%d bytes)\n' "$path" "$_total_bytes"
    else
        printf 'Deleted: %s/ (%d files, %d bytes)\n' "$path" "$_file_count" "$_total_bytes"
    fi
}

tool_edit_file() {
    local path="$1" old_str="$2" new_str="$3"
    if [[ -z "$path" ]]; then
        printf 'ERROR: path is required (empty string received). edit_file needs an absolute file path.\n'
        return 1
    fi
    if [[ ! -f "$path" ]]; then
        printf 'Error: file not found: %s\n' "$path"
        return 1
    fi
    local content
    content=$(<"$path")

    # Check existence and uniqueness in one regex: "$old_str".*"$old_str"
    # matches only when old_str appears at least twice. Fast (~15ms for 600KB)
    # unlike ${content#*"$old_str"} parameter expansion (150-330s on large files).
    if [[ "$content" =~ "$old_str"[[:space:][:print:]]*"$old_str" ]]; then
        printf 'Error: old_string appears multiple times in the file; must be unique\n'
        return 1
    elif [[ "$content" != *"$old_str"* ]]; then
        printf 'Error: old_string not found in file\n'
        return 1
    fi

    # Replace first occurrence (quoted pattern = literal match, not glob)
    local new_content="${content/"$old_str"/"$new_str"}"

    # Show diff before applying
    local tmp_old tmp_new
    tmp_old=$(_mktemp_file 2>/dev/null) || { printf 'Error: mktemp failed\n'; return 1; }
    tmp_new=$(_mktemp_file 2>/dev/null) || { rm -f "$tmp_old"; printf 'Error: mktemp failed\n'; return 1; }
    { printf '%s' "$content" > "$tmp_old"; } 2>/dev/null || { rm -f "$tmp_old" "$tmp_new"; printf 'Error: cannot write temp file\n'; return 1; }
    { printf '%s' "$new_content" > "$tmp_new"; } 2>/dev/null || { rm -f "$tmp_old" "$tmp_new"; printf 'Error: cannot write temp file\n'; return 1; }
    diff -u -L "a/$path" -L "b/$path" "$tmp_old" "$tmp_new" 2>/dev/null | \
        awk '
          function parse_hunk(h, _a, _b) {
            gsub(/^@@ -/, "", h); gsub(/ @@$/, "", h)
            split(h, _a, " +")
            split(_a[1], _b, ","); old_ln=_b[1]+0; old_cnt=(_b[2]!=""?_b[2]+0:1)
            split(_a[2], _b, ","); new_ln=_b[1]+0; new_cnt=(_b[2]!=""?_b[2]+0:1)
          }
          /^@@ -/ { parse_hunk($0); next }
          /^---|\+\+\+/ { next }
          /^ /   { printf "%5d  %s\n", old_ln++, substr($0,2); next }
          /^-/   { printf "%5d -%s\n", old_ln++, substr($0,2); next }
          /^\+/  { printf "%5d +%s\n", new_ln++, substr($0,2); next }
        ' || true
    rm -f "$tmp_old" "$tmp_new"

    # Trace: record edit for undo
    if [[ "${BASHAGT_TRACE_ENABLED:-1}" == "1" ]]; then
        trace_record "$path" "$content" "$new_content" \
            "$(jq -nc --arg agent "${AGENT_SELF_NAME:-main}" --arg tool "edit_file" \
               --arg desc "${BASHAGT_TRACE_DESC:-}" --argjson turn "${TURN_COUNT:-0}" \
               '{agent:$agent, tool:$tool, turn:$turn, desc:$desc}')" 2>/dev/null || true
    fi

    { printf '%s' "$new_content" > "$path"; } 2>/dev/null || {
        printf 'Error: cannot write to: %s\n' "$path"
        return 1
    }
    printf 'File edited successfully: %s\n' "$path"
}

# ── Background bash job submission ──
_bash_submit_bg() {
    local command="$1" timeout="$2"
    mkdir -p "$JOBS_DIR"

    local job_id; job_id="bash_$$_${EPOCHSECONDS:-$(date +%s)}"
    local result_file; result_file=$(_mktemp_file "/tmp/bashagt_job_${job_id}.XXXXXX")
    local _now; _now=${EPOCHSECONDS:-$(date +%s)}

    jq -n --arg id "$job_id" --arg agent "bash" --arg prompt "$command" \
        --arg rf "$result_file" --argjson now "$_now" \
        --argjson timeout "$timeout" \
        '{id:$id, agent:$agent, prompt:$prompt, status:"queued",
          created_at:$now, started_at:0, finished_at:0, pid:0,
          result_file:$rf, result_size:0, timeout:$timeout, error:""}' \
        > "$JOBS_DIR/${job_id}.json"

    _agent_sched_tick
    printf '{"job_id":"%s","status":"queued"}\n' "$job_id"
}

tool_bash() {
    local command="$1" timeout="${2:-120}" background="${3:-false}"
    if [[ -z "$command" ]]; then
        printf 'ERROR: command is required (empty string received). bash tool needs a shell command to execute.\n'
        return 1
    fi

    # ── 参数校验 ──
    local timeout_sec=$timeout
    [[ "$timeout_sec" =~ ^[0-9]+$ ]] || timeout_sec=120
    (( timeout_sec > 600 )) && timeout_sec=600
    (( timeout_sec < 1 ))   && timeout_sec=1

    # ── 后台模式 ──
    if [[ "${background:-false}" == "true" ]]; then
        _bash_submit_bg "$command" "$timeout_sec"
        return $?
    fi

    # ── 同步执行 ──
    local _out_file _pid _pgid _stm_fd
    local exit_code=0 timed_out=0
    _stm_fd=${_BYPASS_FD:-1}

    _out_file=$(_mktemp_file /tmp/bashagt_out.XXXXXX)
    if [[ -z "$_out_file" || ! -f "$_out_file" ]]; then
        printf 'ERROR: mktemp failed\n'; return 1
    fi

    # Auto-add Bashagt co-author trailer to git commits
    if [[ "$command" =~ ^[[:space:]]*git[[:space:]]+commit[[:space:]] ]] && command -v git &>/dev/null; then
        if [[ "${_BASHAGT_GIT_TRAILER_OK:-}" == "1" ]] || git commit --help 2>/dev/null | grep -q '\-\-trailer'; then
            _BASHAGT_GIT_TRAILER_OK=1
            local _trailer='--trailer "Co-Authored-By: Bashagt <bashagt.bot@gmail.com>"'
            command="$command $_trailer"
        fi
    fi

    # 进程启动: setsid → 新会话 + 进程组
    if command -v setsid &>/dev/null; then
        setsid bash -c "$command" > "$_out_file" 2>&1 &
    else
        bash -c "$command" > "$_out_file" 2>&1 &
    fi
    _pid=$!
    _proc_register "$_pid" "tool" "bash: ${cmd:0:100}"
    _pgid=$(ps -o pgid= -p $_pid 2>/dev/null | tr -d ' ')
    [[ -z "$_pgid" ]] && _pgid=$_pid

    # Record PGID for crash recovery (daemon SIGKILL → setsid orphan)
    local _track_pid="${BASHAGT_DAEMON_PID:-$$}"
    local _pgid_file="${TMPDIR:-/tmp}/bashagt_pgid_${_track_pid}_${_pgid}"
    printf '' > "$_pgid_file"

    # 轮询 (100ms)
    local _tick=0.1 _max_ticks=$(( timeout_sec * 10 ))
    local _waited=0 _start_ts; _start_ts=$(_timestamp_ms)
    while kill -0 $_pid 2>/dev/null && (( _waited < _max_ticks )); do
        _spin_sleep $_tick || { _proc_kill "$_pid" TERM; break; }
        _waited=$((_waited + 1))
        (( _waited % 10 == 0 )) && {
            local _ela; _ela=$(ui_time $(( $(_timestamp_ms) - _start_ts )))
            _stream_kv tool_tick elapsed_str "$_ela" >&$_stm_fd
        }
    done

    # 超时处理: TERM → 2s → KILL 整个进程树
    if kill -0 $_pid 2>/dev/null; then
        _proc_kill "$_pid" TERM
        sleep 2
        _proc_kill "$_pid" KILL
        wait $_pid 2>/dev/null
        exit_code=124; timed_out=1
    else
        wait $_pid 2>/dev/null
        exit_code=$?
    fi

    local output; output=$(< "$_out_file")
    rm -f "$_out_file" "$_pgid_file" 2>/dev/null || true

    if (( timed_out )); then
        printf 'Command timed out after %ds. Partial output:\n%s\n' "$timeout_sec" "$output"
    elif [[ "$exit_code" -ne 0 ]]; then
        printf 'exit=%d\n%s\n' "$exit_code" "$output"
    else
        printf '%s\n' "$output"
    fi
}

tool_list_files() {
    local path="$1"
    if [[ -z "$path" ]]; then
        printf 'ERROR: path is required (empty string received). list_files needs a directory path.\n'
        return 1
    fi
    if [[ ! -d "$path" ]]; then
        printf 'Error: not a directory: %s\n' "$path"
        return 1
    fi
    ls -lah "$path" 2>&1
}

# Core web search logic (runs in background via async_spin)
# Outputs structured results to stdout; returns 0 on success, 1 on failure
_web_search_core() {
    local query="$1" max="${2:-3}" timeout="${BASHAGT_WEB_SEARCH_TIMEOUT:-10}"
    local html exit_code

    if [[ "$query" =~ ^https?:// ]]; then
        # --- Direct URL fetch ---
        local _ws_out; _ws_out=$(_mktemp_file /tmp/bashagt_ws.XXXXXX)
        http_get "$query" "$_ws_out" --connect-timeout 5 --max-time "$timeout"
        exit_code=$?
        html=$(< "$_ws_out"); rm -f "$_ws_out"
        if [[ $exit_code -ne 0 ]]; then
            return 1
        fi
        if [[ -z "$html" ]]; then
            return 1
        fi
        local title
        title=$(printf '%s' "$html" | grep -i '<title>' | head -1 | sed 's/<[^>]*>//g' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
        [[ -n "$title" ]] && printf 'Title: %s\n\n' "$title"
        printf '%s' "$html" \
            | sed 's/<[^>]*>//g' \
            | sed '/^[[:space:]]*$/d' \
            | head -$((max * 30))
    else
        # --- Web search with engine fallback chain ---
        # Fallback order: configured engine → remaining in priority (ddg → bing → baidu)
        local primary="${BASHAGT_WEB_SEARCH_ENGINE:-ddg}"
        local engines=("$primary")
        case "$primary" in
            ddg)   engines+=(bing baidu) ;;
            bing)  engines+=(ddg baidu) ;;
            baidu) engines+=(ddg bing) ;;
            *)     engines+=(ddg bing baidu) ;;
        esac

        local per_timeout=$((timeout / 3))
        (( per_timeout < 3 )) && per_timeout=3

        local engine base_url param_name output
        for engine in "${engines[@]}"; do
            case "$engine" in
                bing)  base_url="https://www.bing.com/search?format=rss"; param_name="q" ;;
                baidu) base_url="https://www.baidu.com/s"; param_name="wd" ;;
                *)     base_url="https://lite.duckduckgo.com/lite/"; param_name="q" ;;
            esac
            local _ws_out; _ws_out=$(_mktemp_file /tmp/bashagt_ws.XXXXXX)
            http_get "$base_url" "$_ws_out" \
                --connect-timeout 5 --max-time "$per_timeout" \
                --query-param "$param_name" "$query"
            exit_code=$?
            html=$(< "$_ws_out"); rm -f "$_ws_out"
            [[ $exit_code -ne 0 ]] && continue
            [[ -z "$html" ]] && continue

            if [[ "$engine" == "ddg" ]]; then
                output=$(printf '%s' "$html" | awk -v max="$max" '
                BEGIN { count=0; in_block=0 }
                /<a[^>]*rel="nofollow"[^>]*>/ {
                    if (count >= max) exit
                    if (count > 0) printf "\n"
                    count++
                    line = $0; url = "?"
                    if (match(line, /href="[^"]*"/)) { url = substr(line, RSTART+6, RLENGTH-7) }
                    gsub(/<[^>]*>/, ""); gsub(/^[[:space:]]+|[[:space:]]+$/, ""); title = $0
                    printf "[%d] %s\n    URL: %s", count, title, url
                    in_block = 1; next
                }
                /<\/tr>/ { in_block = 0; next }
                in_block {
                    line = $0
                    gsub(/<[^>]*>/, "", line); gsub(/^[[:space:]]+|[[:space:]]+$/, "", line)
                    if (line != "" && line !~ /^https?:\/\// && line != title && index(url, line) == 0) {
                        printf "\n    %s", line
                    }
                }
                END { if (count > 0) printf "\n" }
                ' 2>/dev/null)
                if [[ -n "$output" ]]; then printf '%s\n' "$output"; return 0; fi
                # DDG structured failed, try raw fallback
                output=$(printf '%s' "$html" | sed 's/<[^>]*>//g' | sed '/^[[:space:]]*$/d' | head -$((max * 15)))
                [[ -n "$output" ]] && { printf '%s\n' "$output"; return 0; }

            elif [[ "$engine" == "bing" ]]; then
                output=$(printf '%s' "$html" | awk -v max="$max" '
                BEGIN { RS="<item>"; count=0 }
                NR > 1 {
                    if (count >= max) exit
                    count++
                    s=$0; sub(/<\/item>.*/, "", s)
                    t=s; sub(/.*<title>/, "", t); sub(/<\/title>.*/, "", t)
                    gsub(/&amp;/, "\\&", t); gsub(/&lt;/, "<", t); gsub(/&gt;/, ">", t); gsub(/&quot;/, "\"", t)
                    l=s; sub(/.*<link>/, "", l); sub(/<\/link>.*/, "", l)
                    d=s; sub(/.*<description>/, "", d); sub(/<\/description>.*/, "", d)
                    gsub(/&amp;/, "\\&", d); gsub(/&lt;/, "<", d); gsub(/&gt;/, ">", d); gsub(/&quot;/, "\"", d)
                    printf "[%d] %s\n    URL: %s\n    %s\n\n", count, t, l, d
                }
                ' 2>/dev/null)
                [[ -n "$output" ]] && { printf '%s\n' "$output"; return 0; }

            else
                output=$(printf '%s' "$html" \
                    | sed '/<script[^>]*>/,/<\/script>/d' \
                    | sed '/<style[^>]*>/,/<\/style>/d' \
                    | sed 's/<[^>]*>//g' \
                    | sed '/^[[:space:]]*$/d' \
                    | head -$((max * 15)))
                [[ -n "$output" ]] && { printf '%s\n' "$output"; return 0; }
            fi
        done
        return 1
    fi
}

tool_web_search() {
    local query="$1" max="${2:-3}" desc="${3:-}"
    local _label
    if [[ -n "$desc" && "$desc" != "null" ]]; then
        _label="$desc"
    else
        _label="web_search (${query:0:40})"
    fi
    (( ${#_label} > 80 )) && _label="${_label:0:77}..."
    local _tmp; _tmp=$(_mktemp_file /tmp/bashagt_ws.XXXXXX 2>/dev/null || mktemp -t bashagt_ws 2>/dev/null)
    if [[ -z "$_tmp" ]] || [[ ! -f "$_tmp" ]]; then
        _web_search_core "$query" "$max"
        return $?
    fi
    async_spin --dot "$_label" "done" "$_tmp" _web_search_core "$query" "$max"
    local rc=$?
    if [[ $rc -eq 0 ]]; then
        printf '%s' "$_async_out"
    else
        printf 'Web search failed or returned no results.\n'
    fi
    rm -f "$_tmp"
    return $rc
}

# ============================================================================
# SECTION 10: Tool Dispatcher
# ============================================================================

# ── Centralized tool input logging ──
# Called from dispatch_tool() before case dispatch.  Extracts tool-specific
# params and emits a single structured DEBUG log line per invocation.
# bash commands are logged in full (no truncation); other large content is
# truncated to a reasonable limit.
_log_tool_input() {
    (( LOG_LEVEL_NUM > 0 )) && return 0
    local name="$1" input_json="$2"
    local _path _offset _limit _content _old _new _cmd _to _bg
    local _agent _prompt _async _query _max_r _ptext _tid _tstatus _tsubj
    local _jid _sname _stask _scount _opts

    case "$name" in
        read_file)
            _path=$(jq -r '.path // ""' <<< "$input_json"); _offset=$(jq -r '.offset // ""' <<< "$input_json"); _limit=$(jq -r '.limit // ""' <<< "$input_json")
            log "DEBUG: TOOL_INPUT read_file  path=$_path offset=$_offset limit=$_limit" ;;
        write_file)
            _path=$(jq -r '.path // ""' <<< "$input_json"); _content=$(jq -r '.content // ""' <<< "$input_json")
            log "DEBUG: TOOL_INPUT write_file  path=$_path content_len=${#_content} content_snip=\"${_content:0:256}\"" ;;
        edit_file)
            _path=$(jq -r '.path // ""' <<< "$input_json"); _old=$(jq -r '.old_string // ""' <<< "$input_json"); _new=$(jq -r '.new_string // ""' <<< "$input_json")
            log "DEBUG: TOOL_INPUT edit_file   path=$_path old_len=${#_old} new_len=${#_new} old_snip=\"${_old:0:256}\" new_snip=\"${_new:0:256}\"" ;;
        list_files)
            _path=$(jq -r '.path // ""' <<< "$input_json")
            log "DEBUG: TOOL_INPUT list_files  path=$_path" ;;
        bash)
            _cmd=$(jq -r '.command // ""' <<< "$input_json"); _to=$(jq -r '.timeout // "120"' <<< "$input_json"); _bg=$(jq -r '.background // "false"' <<< "$input_json")
            log "DEBUG: TOOL_INPUT bash        cmd=\"$_cmd\" timeout=$_to bg=$_bg" ;;
        web_search)
            _query=$(jq -r '.query // ""' <<< "$input_json"); _max_r=$(jq -r '.max_results // "3"' <<< "$input_json")
            log "DEBUG: TOOL_INPUT web_search  query=\"$_query\" max=$_max_r" ;;
        agent)
            _agent=$(jq -r '.agent // ""' <<< "$input_json"); _prompt=$(jq -r '.prompt // ""' <<< "$input_json"); _async=$(jq -r '.async // "false"' <<< "$input_json")
            log "DEBUG: TOOL_INPUT agent       to=$_agent prompt_len=${#_prompt} prompt_snip=\"${_prompt:0:512}\" async=$_async" ;;
        agent_batch)
            _scount=$(jq -r '.tasks | length' <<< "$input_json" 2>/dev/null || echo 0)
            log "DEBUG: TOOL_INPUT agent_batch task_count=$_scount" ;;
        agent_status)
            log "DEBUG: TOOL_INPUT agent_status" ;;
        job_poll)
            _jid=$(jq -r '.job_id // ""' <<< "$input_json")
            log "DEBUG: TOOL_INPUT job_poll    job_id=$_jid" ;;
        job_result)
            _jid=$(jq -r '.job_id // ""' <<< "$input_json"); _to=$(jq -r '.timeout // "30"' <<< "$input_json")
            log "DEBUG: TOOL_INPUT job_result  job_id=$_jid timeout=$_to" ;;
        job_cancel)
            _jid=$(jq -r '.job_id // ""' <<< "$input_json")
            log "DEBUG: TOOL_INPUT job_cancel  job_id=$_jid" ;;
        make_todos)
            _ptext=$(jq -r '.plan_text // ""' <<< "$input_json")
            log "DEBUG: TOOL_INPUT make_todos  plan_text_len=${#_ptext} plan_snip=\"${_ptext:0:256}\"" ;;
        task_update)
            _tid=$(jq -r '.id // ""' <<< "$input_json"); _tstatus=$(jq -r '.status // ""' <<< "$input_json"); _tsubj=$(jq -r '.subject // ""' <<< "$input_json")
            log "DEBUG: TOOL_INPUT task_update id=$_tid status=$_tstatus subject=\"$_tsubj\"" ;;
        task_list)
            log "DEBUG: TOOL_INPUT task_list" ;;
        send_message)
            _to=$(jq -r '.to // ""' <<< "$input_json"); _content=$(jq -r '.content // ""' <<< "$input_json")
            log "DEBUG: TOOL_INPUT send_message to=$_to content_len=${#_content} content_snip=\"${_content:0:256}\"" ;;
        check_messages)
            log "DEBUG: TOOL_INPUT check_messages" ;;
        skill)
            _sname=$(jq -r '.name // ""' <<< "$input_json"); _stask=$(jq -r '.task // ""' <<< "$input_json")
            log "DEBUG: TOOL_INPUT skill       name=$_sname task_len=${#_stask} task_snip=\"${_stask:0:256}\"" ;;
        list_skills)
            log "DEBUG: TOOL_INPUT list_skills" ;;
        list_agents)
            log "DEBUG: TOOL_INPUT list_agents" ;;
        list_mcp_tools)
            log "DEBUG: TOOL_INPUT list_mcp_tools" ;;
        request)
            _prompt=$(jq -r '.prompt // ""' <<< "$input_json"); _opts=$(jq -r '.options | length' <<< "$input_json" 2>/dev/null || echo 0); _query=$(jq -r '.context // ""' <<< "$input_json")
            log "DEBUG: TOOL_INPUT request     prompt=\"$_prompt\" option_count=$_opts context=\"${_query:0:120}\"" ;;
        mcp__*)
            local _srv="${name#mcp__}"; _srv="${_srv%%__*}"; local _tool="${name#mcp__${_srv}__}"
            log "DEBUG: TOOL_INPUT mcp         server=$_srv tool=$_tool args_size=${#input_json}" ;;
        delete_file)
            _path=$(jq -r '.path // ""' <<< "$input_json")
            _bg=$(jq -r '.recursive // false' <<< "$input_json")
            log "DEBUG: TOOL_INPUT delete_file path=$_path recursive=$_bg" ;;
        undo)
            _to=$(jq -r '.frames // "1"' <<< "$input_json")
            _bg=$(jq -r '.force // false' <<< "$input_json")
            log "DEBUG: TOOL_INPUT undo        frames=$_to force=$_bg" ;;
        *)
            log "DEBUG: TOOL_INPUT ?          name=$name args_size=${#input_json}" ;;
    esac
}

# ── Centralized tool completion logging ──
_log_tool_done() {
    local name="$1" rc="${2:-0}" out_size="${3:-0}"
    log "DEBUG: TOOL_DONE  $name      rc=$rc output_size=$out_size"
}

# ── Parse diff -u output into line-numbered +/- format (POSIX awk) ──
# Reuses the same parse_hunk pattern as tool_edit_file.
_safe_diff_awk() {
    awk '
      function parse_hunk(h, _a, _b) {
        gsub(/^@@ -/, "", h); gsub(/ @@$/, "", h)
        split(h, _a, " +")
        split(_a[1], _b, ","); old_ln=_b[1]+0; old_cnt=(_b[2]!=""?_b[2]+0:1)
        split(_a[2], _b, ","); new_ln=_b[1]+0; new_cnt=(_b[2]!=""?_b[2]+0:1)
      }
      /^@@ -/ { parse_hunk($0); next }
      /^---|\+\+\+/ { next }
      /^ /   { printf "%5d  %s\n", old_ln++, substr($0,2); next }
      /^-/   { printf "%5d -%s\n", old_ln++, substr($0,2); next }
      /^\+/  { printf "%5d +%s\n", new_ln++, substr($0,2); next }
    '
}

# ── Simple bash syntax highlighter + layout formatter ──
_bash_format() {
    local _text _prog
    if [[ -n "${1:-}" ]]; then
        _text="$1"
    else
        _text=$(dd bs=1048576 count=1 2>/dev/null)
    fi
    [[ -z "$_text" ]] && return 0
    IFS= read -rd '' _prog <<'AWK_EOF'
BEGIN { kw["if"]=1; kw["then"]=1; kw["else"]=1; kw["elif"]=1; kw["fi"]=1
    kw["for"]=1; kw["while"]=1; kw["until"]=1; kw["do"]=1; kw["done"]=1
    kw["case"]=1; kw["esac"]=1; kw["in"]=1; kw["function"]=1
    kw["return"]=1; kw["exit"]=1; kw["export"]=1; kw["local"]=1
    kw["declare"]=1; kw["typeset"]=1; kw["readonly"]=1
    kw["eval"]=1; kw["source"]=1; kw["exec"]=1; kw["trap"]=1
    kw["set"]=1; kw["unset"]=1; kw["shift"]=1; kw["break"]=1; kw["continue"]=1
    kw["echo"]=1; kw["printf"]=1; kw["cd"]=1; kw["wait"]=1; kw["kill"]=1
    kw["test"]=1; kw["alias"]=1; kw["unalias"]=1
    kw["time"]=1; kw["type"]=1; kw["command"]=1; kw["builtin"]=1
    fn["ls"]=1; fn["cat"]=1; fn["cp"]=1; fn["mv"]=1; fn["rm"]=1
    fn["mkdir"]=1; fn["rmdir"]=1; fn["touch"]=1; fn["find"]=1
    fn["grep"]=1; fn["sed"]=1; fn["awk"]=1; fn["cut"]=1; fn["sort"]=1
    fn["uniq"]=1; fn["wc"]=1; fn["head"]=1; fn["tail"]=1; fn["diff"]=1
    fn["chmod"]=1; fn["chown"]=1; fn["ln"]=1; fn["stat"]=1; fn["du"]=1
    fn["df"]=1; fn["file"]=1; fn["basename"]=1; fn["dirname"]=1
    fn["ps"]=1; fn["killall"]=1; fn["sleep"]=1; fn["xargs"]=1
    fn["nohup"]=1; fn["pgrep"]=1; fn["pkill"]=1; fn["nice"]=1
    fn["curl"]=1; fn["wget"]=1; fn["ssh"]=1; fn["rsync"]=1
    fn["ping"]=1; fn["nc"]=1; fn["scp"]=1; fn["netstat"]=1
    fn["git"]=1; fn["make"]=1; fn["python"]=1; fn["python3"]=1
    fn["node"]=1; fn["npm"]=1; fn["pip"]=1; fn["pip3"]=1; fn["gcc"]=1
    fn["sudo"]=1; fn["date"]=1; fn["which"]=1; fn["env"]=1
    fn["tee"]=1; fn["tr"]=1; fn["uname"]=1; fn["whoami"]=1
    fn["id"]=1; fn["groups"]=1; fn["su"]=1; fn["mount"]=1
    fn["umount"]=1; fn["systemctl"]=1; fn["service"]=1
    fn["true"]=1; fn["false"]=1; fn["yes"]=1
    fn["more"]=1; fn["less"]=1; fn["vi"]=1; fn["vim"]=1; fn["nano"]=1
}
{
    line = $0; out = ""; state = 0; i = 1; len = length(line); _and_count = 0
    was_cont = (FNR > 1 && prev_cont)
    if (was_cont) { indent = prev_base + 3 }
    else          { indent = 0; prev_base = 0 }
    for (j = 0; j < indent; j++) out = out " "
    if (indent > 0) { sub(/^[[:space:]]+/, "", line); len = length(line) }
    if (line ~ /^[[:space:]]*#/) {
        m1 = line; sub(/[^[:space:]].*/, "", m1)
        m2 = line; sub(/^[[:space:]]*/, "", m2)
        out = out m1 _cmt m2 _reset
        printf "%s\n", out; prev_cont = 0; next
    }
    while (i <= len) {
        ch = substr(line, i, 1)
        if (state == 0 && (ch == "\"" || ch == "'")) {
            state = (ch == "\"" ? 1 : 2)
            out = out _str ch; i++; continue
        }
        if ((state == 1 && ch == "\"") || (state == 2 && ch == "'")) {
            out = out ch _reset; state = 0; i++; continue
        }
        if (state > 0) { out = out ch; i++; continue }
        # Semicolon line-break (state 0 only, NOT inside strings)
        if (ch == ";") {
            if (i < len && substr(line, i+1, 1) == ";") {
                out = out ";;"; i += 2
                while (i <= len && substr(line, i, 1) == " ") i++
                out = out "\n"; continue
            } else {
                out = out ";"; i++
                while (i <= len && substr(line, i, 1) == " ") i++
                out = out "\n"; continue
            }
        }
        # && line-break: every && wraps with \ (state 0, not inside strings)
        if (ch == "&" && i < len && substr(line, i+1, 1) == "&") {
            _and_count++
            out = out "&& \\"; i += 2
            while (i <= len && substr(line, i, 1) == " ") i++
            out = out "\n    "
            continue
        }
        if (ch == "$") {
            rest = substr(line, i); vlen = 0
            if (rest ~ /^\$\{[^}]*\}/)      { vlen = index(rest, "}") + 1 }
            else if (rest ~ /^\$[A-Za-z_][A-Za-z0-9_]*/) {
                match(rest, /^\$[A-Za-z_][A-Za-z0-9_]*/); vlen = RLENGTH
            } else if (rest ~ /^\$[@#?*!0-9\$-]/) { vlen = 2 }
            if (vlen > 0) {
                out = out _var substr(line, i, vlen) _reset
                i += vlen; continue
            }
        }
        if (ch ~ /[[:alnum:]_]/) {
            rest = substr(line, i)
            match(rest, /^[[:alnum:]_]+/)
            word = substr(rest, 1, RLENGTH)
            if (kw[word])      { out = out _kw word _reset }
            else if (fn[word]) { out = out _fn word _reset }
            else               { out = out word }
            i += RLENGTH; continue
        }
        out = out ch; i++
    }
    prev_cont = 0
    if (line ~ /\\[[:space:]]*$/)       { prev_cont = 1 }
    if (line ~ /[|&][|&]?[[:space:]]*$/) { prev_cont = 1 }
    if (prev_cont && !was_cont)         { prev_base = indent }
    if (line ~ /^[[:space:]]*[|&]/)     { prev_cont = 0; prev_base = 0 }
    printf "%s\n", out
}
AWK_EOF
    printf '%s' "$_text" | awk \
        -v _kw="$KW" -v _str="$STR" -v _cmt="$CMT" \
        -v _fn="$FN" -v _var="$VAR" -v _num="$NUM" \
        -v _cls="$CLS" -v _dec="$DEC" -v _esc="$ESC" \
        -v _reset="$RESET" \
        "$_prog"
}

# ── Safe mode diff preview (writes directly to /dev/tty) ──
_safe_preview_diff() {
    local _tool="$1" _input="$2"

    # Guard: skip if no TTY (daemon/worker mode)
    (exec 2>/dev/tty) 2>/dev/null || return 0

    local _path; _path=$(jq -r '.path // ""' <<< "$_input")
    local _diff_text _diff_max=25 _line_count=0

    if [[ ! -f "$_path" && "$_tool" == "edit_file" ]]; then
        return 0
    fi

    case "$_tool" in
        write_file)
            local _content; _content=$(jq -r '.content // ""' <<< "$_input")
            if [[ -f "$_path" ]]; then
                local _old_content; _old_content=$(<"$_path")
                _diff_text=$(diff -u <(printf '%s' "$_old_content") <(printf '%s' "$_content") 2>/dev/null \
                    | _safe_diff_awk)
            else
                _diff_text=$(printf '%s' "$_content" \
                    | awk '{printf "%5d +%s\n", NR, $0}')
            fi
            ;;
        edit_file)
            local _old_str _new_str
            _old_str=$(jq -r '.old_string // ""' <<< "$_input")
            _new_str=$(jq -r '.new_string // ""' <<< "$_input")
            if [[ -f "$_path" ]]; then
                local _content; _content=$(<"$_path")
                local _new_content="${_content/"$_old_str"/$_new_str}"
                _diff_text=$(diff -u <(printf '%s' "$_content") <(printf '%s' "$_new_content") 2>/dev/null \
                    | _safe_diff_awk)
            fi
            ;;
        bash)
            local _cmd; _cmd=$(jq -r '.command // ""' <<< "$_input")
            _diff_text=$(printf '%s' "$_cmd" \
                | _bash_format \
                | awk '{printf "%5d  %s\n", NR, $0}')
            ;;
    esac

    [[ -z "$_diff_text" ]] && return 0

    _line_count=$(printf '%s' "$_diff_text" | wc -l)
    if (( _line_count > _diff_max )); then
        _diff_text=$(printf '%s' "$_diff_text" | head -n "$_diff_max")
        _diff_text+=$'\n'"  ...[truncated — showing first $_diff_max of $_line_count lines]..."
    fi

    # call_api_nonstreaming already emitted status_done to FIFO.
    # Brief sleep lets renderer process it and kill the spinner timer
    # before we write preview directly to /dev/tty.
    sleep 0.1

    # Write preview directly to /dev/tty — synchronous, correct order: preview → dialog
    printf '\r\n' > /dev/tty

    local _header
    if [[ "$_tool" == "bash" ]]; then
        printf '  %s── Command ──%s\r\n' "$DIM" "$RESET" > /dev/tty
    else
        printf '  %s── Preview: %s%s%s ──%s\r\n' \
            "$DIM" "$BOLD" "$_path" "$RESET" "$DIM" > /dev/tty
    fi

    while IFS= read -r _line; do
        if [[ "$_line" =~ ^[[:space:]]*[0-9]+\ \+ ]]; then
            printf '  %s%s%s\033[K%s\r\n' "$DIFF_ADD_BG" "$DIFF_ADD_FG" "$_line" "$RESET" > /dev/tty
        elif [[ "$_line" =~ ^[[:space:]]*[0-9]+\ - ]]; then
            printf '  %s%s%s\033[K%s\r\n' "$DIFF_DEL_BG" "$DIFF_DEL_FG" "$_line" "$RESET" > /dev/tty
        else
            printf '  %s\r\n' "$_line" > /dev/tty
        fi
    done <<< "$_diff_text"

    printf '  %s── End preview ──%s\r\n' "$DIM" "$RESET" > /dev/tty
}

# ── Safe mode confirmation (before dispatch_tool — uses _request_ui via $()) ──
_safe_confirm() {
    local _tool="$1" _input="$2"
    local _prompt _context=""

    # Daemon/stream mode: skip (no TTY for _request_ui)
    _request_detect_mode || return 0

    case "$_tool" in
        bash)
            _safe_preview_diff "bash" "$_input"
            _prompt="Execute this command?"
            ;;
        write_file)
            local _path; _path=$(jq -r '.path // ""' <<< "$_input")
            local _content; _content=$(jq -r '.content // ""' <<< "$_input")
            _safe_preview_diff "write_file" "$_input"
            _prompt="Write ${#_content} bytes to $_path?"
            ;;
        edit_file)
            local _path; _path=$(jq -r '.path // ""' <<< "$_input")
            _safe_preview_diff "edit_file" "$_input"
            _prompt="Apply edit to $_path?"
            ;;
        delete_file)
            local _path; _path=$(jq -r '.path // ""' <<< "$_input")
            _prompt="Delete $_path?"
            ;;
    esac

    local _req_result _choice
    _req_result=$(tool_request "$_prompt" '["Yes, execute","No, cancel"]' "$_context" 2>/dev/null)
    _choice=$(jq -r '.choice // ""' <<< "$_req_result" 2>/dev/null)
    [[ "$_choice" == "Yes, execute" ]] && return 0
    return 1
}

dispatch_tool() {
    local name="$1" input_json="$2"

    # Simple single-line fields: extracted with one combined jq + read (fast).
    # Multi-line fields (content, old_string, new_string, command, prompt):
    #   extracted with dedicated jq + $() which preserves newlines.
    local _S _path _offset _limit _agent _query _max_r
    _S=$(jq -r '
      (.path // ""),
      (.offset // "1"),
      (.limit // "500"),
      (.agent // ""),
      (.query // ""),
      (.max_results // "3")
    ' <<< "$input_json" 2>/dev/null)
    { IFS= read -r _path; IFS= read -r _offset; IFS= read -r _limit;
      IFS= read -r _agent; IFS= read -r _query; IFS= read -r _max_r; } <<< "$_S"

    _log_tool_input "$name" "$input_json"

    # Export trace context for trace_record hooks
    export BASHAGT_TRACE_DESC="$(jq -r '.description // ""' <<< "$input_json")"
    export BASHAGT_TRACE_TOOL="$name"

    case "$name" in
        read_file)    tool_read_file "$_path" "$_offset" "$_limit" ;;
        write_file)
            local _content; _content=$(jq -r '.content // ""' <<< "$input_json")
            tool_write_file "$_path" "$_content" ;;
        edit_file)
            local _old_str _new_str
            _old_str=$(jq -r '.old_string // ""' <<< "$input_json")
            _new_str=$(jq -r '.new_string // ""' <<< "$input_json")
            tool_edit_file "$_path" "$_old_str" "$_new_str" ;;
        bash)
            local _cmd _to _bg
            _cmd=$(jq -r '.command // ""' <<< "$input_json")
            _to=$(jq -r '.timeout // "120"' <<< "$input_json")
            _bg=$(jq -r '.background // "false"' <<< "$input_json")
            tool_bash "$_cmd" "$_to" "$_bg" ;;
        list_files)   tool_list_files "$_path" ;;
        web_search)
            local _wsdesc
            _wsdesc=$(jq -r '.description // ""' <<< "$input_json")
            tool_web_search "$_query" "$_max_r" "$_wsdesc" ;;
        agent)
            local _prompt _async _adesc
            _prompt=$(jq -r '.prompt // ""' <<< "$input_json")
            _async=$(jq -r '.async // false' <<< "$input_json")
            _adesc=$(jq -r '.description // ""' <<< "$input_json")
            tool_agent "$_agent" "$_prompt" "$_async" "$_adesc" ;;
        agent_batch)
            tool_agent_batch "$input_json" ;;
        make_todos)
            local _ptext _mtdesc
            _ptext=$(jq -r '.plan_text // ""' <<< "$input_json")
            _mtdesc=$(jq -r '.description // ""' <<< "$input_json")
            tool_make_todos "$_ptext" "$_mtdesc" ;;
        task_update)
            local _tid _tstatus _tsubj _tdsc
            _tid=$(jq -r '.id // ""' <<< "$input_json")
            _tstatus=$(jq -r '.status // ""' <<< "$input_json")
            _tsubj=$(jq -r '.subject // ""' <<< "$input_json")
            _tdsc=$(jq -r '.description // ""' <<< "$input_json")
            tool_task_update "$_tid" "$_tstatus" "$_tsubj" "$_tdsc" ;;
        task_list)     tool_task_list ;;
        send_message)
            local _to _content
            _to=$(jq -r '.to // ""' <<< "$input_json")
            _content=$(jq -r '.content // ""' <<< "$input_json")
            tool_send_message "$_to" "$_content" ;;
        check_messages)
            tool_check_messages ;;
        agent_status)
            agent_status ;;
        job_poll)
            local _jid; _jid=$(jq -r '.job_id // ""' <<< "$input_json")
            agent_poll "$_jid" ;;
        job_result)
            local _jid _to; _jid=$(jq -r '.job_id // ""' <<< "$input_json")
            _to=$(jq -r '.timeout // 30' <<< "$input_json")
            agent_result "$_jid" "$_to" ;;
        job_cancel)
            local _jid; _jid=$(jq -r '.job_id // ""' <<< "$input_json")
            agent_cancel "$_jid" && printf 'Job %s cancelled.\n' "$_jid" || printf 'Job %s cannot be cancelled.\n' "$_jid" ;;
        skill)
            local _sname _stask
            _sname=$(jq -r '.name // ""' <<< "$input_json")
            _stask=$(jq -r '.task // ""' <<< "$input_json")
            tool_skill "$_sname" "$_stask" ;;
        list_skills)       tool_list_skills ;;
        list_agents)       tool_list_agents ;;
        list_mcp_tools)    tool_list_mcp_tools ;;
        request)
            local _prompt _options_json _context
            _prompt=$(jq -r '.prompt // ""' <<< "$input_json")
            _options_json=$(jq -c '.options // []' <<< "$input_json")
            _context=$(jq -r '.context // ""' <<< "$input_json")
            tool_request "$_prompt" "$_options_json" "$_context" ;;
        mcp__*)
            local _mcp_srv _mcp_tool
            _mcp_srv="${name#mcp__}"; _mcp_srv="${_mcp_srv%%__*}"
            _mcp_tool="${name#mcp__${_mcp_srv}__}"
            mcp_dispatch_tool "$_mcp_srv" "$_mcp_tool" "$input_json" ;;
        delete_file)
            local _df_recursive
            _df_recursive=$(jq -r '.recursive // false' <<< "$input_json")
            tool_delete_file "$_path" "$_df_recursive" ;;
        undo)
            local _u_frames _u_reason _u_force _u_desc
            _u_frames=$(jq -r '.frames // "1"' <<< "$input_json")
            _u_reason=$(jq -r '.reason // ""' <<< "$input_json")
            _u_force=$(jq -r '.force // false' <<< "$input_json")
            _u_desc=$(jq -r '.description // ""' <<< "$input_json")
            _trace_undo_for_model "$_u_frames" "$_u_reason" "$_u_desc" "$_u_force" ;;
        *) printf 'Unknown tool: %s\n' "$name"; return 1 ;;
    esac
}

# ============================================================================
# SECTION 10a: Human Oversight — request tool
# ============================================================================

# Global flag: set by _request_async() to force end_turn in daemon mode
_REQUEST_PENDING=0
_RESPONSE_FILE=""  # set by _request_async in oneshot stream mode for run_turn polling
BASHAGT_SAFE_MODE=0          # /safe toggle: 1 = require confirmation for destructive tools

# ── Mode detection: use blocking TUI when /dev/tty is available ──
_request_detect_mode() {
    # Daemon workers set BASHAGT_DAEMON_WORKER=1 — always async
    [[ "${BASHAGT_DAEMON_WORKER:-0}" == "1" ]] && return 1
    # Raw JSONL mode (hotkey --stream, daemon worker --stream): bashagt runs in
    # background/pipeline and cannot access /dev/tty (SIGTTIN). Interactive UI
    # is handled by the foreground renderer. Must use async.
    # NOTE: BASHAGT_STREAM_MODE is always 1 (set in main()), so we check
    # BASHAGT_OE_RAW which is only set by the --stream flag.
    (( ${BASHAGT_OE_RAW:-0} )) && return 1
    # Interactive mode: /dev/tty is the controlling terminal and we are foreground
    (exec 2>/dev/tty) 2>/dev/null && return 0
    return 1
}

# ── Render the option menu to /dev/tty ──
# ── CJK-aware terminal display width ──
# bash ${#var} counts characters (1 CJK char = 1) but terminals display
# CJK chars as 2 columns.  Detect by Unicode codepoint > 127.
_req_disp_width() {
    _str_display_width "$1"
}

# Render one content line: "│  <text><pad> │" with CJK-correct padding.
# Usage: _req_content_line <text> <max_display_width> [dim]
_req_content_line() {
    local _text="$1" _pw="$2" _dim="${3:-0}" _disp _pad
    _text="${_text//$'\n'/ }"
    _disp=$(_req_disp_width "$_text")
    _pad=$((_pw - _disp))
    (( _pad < 0 )) && _pad=0
    if (( _dim )); then
        printf '│  %s%s%*s%s │\r\n' "$DIM" "$_text" "$_pad" '' "$RESET" > /dev/tty
    else
        printf '│  %s%*s │\r\n' "$_text" "$_pad" '' > /dev/tty
    fi
}

_request_ui_render() {
    local _prompt="$1" _context="$2" _opt_labels_name="$3" _opt_count="$4"
    local _selected="$5" _i _idx _label _width _pad _disp
    local -n _opts="$_opt_labels_name"
    _REQ_LINE_COUNT=0

    # Dynamic width: use terminal width (capped at 80), fallback to 62
    _width=${COLUMNS:-$(tput cols 2>/dev/null || echo 80)}
    (( _width > 80 )) && _width=80
    (( _width < 40 )) && _width=62

    # Border string
    local _border=""
    for ((_i=0; _i<_width-4; _i++)); do _border+="─"; done

    # Content area: between left "│  " and right " │" = _width - 4 columns
    local _cw=$((_width - 5))

    # CRITICAL: stty raw disables opost — every \n MUST have \r
    # Clear from cursor to end of screen (preserves history above)

    # Top border
    printf '╭─%s─╮\r\n' "$_border" > /dev/tty; _REQ_LINE_COUNT=$((_REQ_LINE_COUNT + 1))

    # Title: "│ " + left-justified-content(_width-4) + " │" = _width cols
    printf '│ %-*s │\r\n' "$((_width - 4))" 'Human Oversight' > /dev/tty; _REQ_LINE_COUNT=$((_REQ_LINE_COUNT + 1))
    # Empty separator: "│" + spaces(_width-2) + "│" = _width cols
    printf '│%*s│\r\n' "$((_width - 2))" '' > /dev/tty; _REQ_LINE_COUNT=$((_REQ_LINE_COUNT + 1))

    # Prompt: CJK-safe rendering
    _disp=$(_req_disp_width "$_prompt")
    if (( _disp <= _cw )); then
        _req_content_line "$_prompt" "$_cw" 0; _REQ_LINE_COUNT=$((_REQ_LINE_COUNT + 1))
    else
        # Truncate with ellipsis (character-safe)
        local _trimmed="" _t_disp=0 _ch _cd
        for ((_i=0; _i<${#_prompt}; _i++)); do
            _ch="${_prompt:_i:1}"
            printf -v _cp '%d' "'$_ch" 2>/dev/null || true
            _cd=1; _is_wide_codepoint "$_cp" && _cd=2; _is_zero_width_codepoint "$_cp" && _cd=0
            (( _t_disp + _cd + 2 > _cw )) && { _trimmed+="…"; break; }
            _trimmed+="$_ch"
            _t_disp=$((_t_disp + _cd))
        done
        _req_content_line "$_trimmed" "$_cw" 0; _REQ_LINE_COUNT=$((_REQ_LINE_COUNT + 1))
    fi

    # Context (dim text)
    if [[ -n "$_context" ]]; then
        printf '│%*s│\r\n' "$((_width - 2))" '' > /dev/tty; _REQ_LINE_COUNT=$((_REQ_LINE_COUNT + 1))
        _disp=$(_req_disp_width "$_context")
        if (( _disp <= _cw )); then
            _req_content_line "$_context" "$_cw" 1; _REQ_LINE_COUNT=$((_REQ_LINE_COUNT + 1))
        else
            local _trimmed="" _t_disp=0 _ch _cd
            for ((_i=0; _i<${#_context}; _i++)); do
                _ch="${_context:_i:1}"
                printf -v _cp '%d' "'$_ch" 2>/dev/null || true
                _cd=1; _is_wide_codepoint "$_cp" && _cd=2; _is_zero_width_codepoint "$_cp" && _cd=0
                (( _t_disp + _cd + 2 > _cw )) && { _trimmed+="…"; break; }
                _trimmed+="$_ch"
                _t_disp=$((_t_disp + _cd))
            done
            _req_content_line "$_trimmed" "$_cw" 1; _REQ_LINE_COUNT=$((_REQ_LINE_COUNT + 1))
        fi
    fi

    printf '│%*s│\r\n' "$((_width - 2))" '' > /dev/tty; _REQ_LINE_COUNT=$((_REQ_LINE_COUNT + 1))

    # Options
    for ((_i=0; _i<_opt_count; _i++)); do
        _idx=$((_i + 1))
        _label="${_opts[$_i]}"
        _disp=$(_req_disp_width "$_label")

        if (( _i == _selected )); then
            # "│  ❯ N. label<pad> │"  — prefix: 3+2+2+${#_idx}=7+${#_idx} cols
            _pad=$((_width - 3 - 2 - 2 - ${#_idx} - _disp - 2))
            (( _pad < 0 )) && _pad=0
            printf '│  %s❯ %s. %s%s%*s │\r\n' \
                "$CYAN" "$_idx" "$_label" "$RESET" "$_pad" '' > /dev/tty; _REQ_LINE_COUNT=$((_REQ_LINE_COUNT + 1))
        else
            # "│    N. label<pad> │"  — prefix: 5+2+${#_idx}=7+${#_idx} cols
            _pad=$((_width - 5 - 2 - ${#_idx} - _disp - 2))
            (( _pad < 0 )) && _pad=0
            printf '│    %s. %s%*s │\r\n' \
                "$_idx" "$_label" "$_pad" '' > /dev/tty; _REQ_LINE_COUNT=$((_REQ_LINE_COUNT + 1))
        fi
    done

    printf '│%*s│\r\n' "$((_width - 2))" '' > /dev/tty; _REQ_LINE_COUNT=$((_REQ_LINE_COUNT + 1))
    printf '╰─%s─╯\r\n' "$_border" > /dev/tty; _REQ_LINE_COUNT=$((_REQ_LINE_COUNT + 1))

    # Footer
    printf '  %s↑↓ navigate  Enter select  Esc/q cancel%s\r\n' "$DIM" "$RESET" > /dev/tty; _REQ_LINE_COUNT=$((_REQ_LINE_COUNT + 1))
}

# ── Blocking TUI: arrow-key menu on /dev/tty ──
_request_ui() {
    local _prompt="$1" _options_json="$2" _context="$3"
    local _selected=0 _opt_count _key
    local _opt_labels=() _i

    _opt_count=$(jq 'length' <<< "$_options_json" 2>/dev/null)
    if (( _opt_count < 2 )); then
        printf 'ERROR: request requires at least 2 options\n'
        return 1
    fi
    if (( _opt_count > 9 )); then
        printf 'ERROR: request supports at most 9 options\n'
        return 1
    fi

    # Parse options into indexed array
    for ((_i=0; _i<_opt_count; _i++)); do
        _opt_labels[$_i]=$(jq -r ".[$_i]" <<< "$_options_json" 2>/dev/null)
    done

    # _request_ui_render truncates wide prompts to 1 line (ellipsis), so \n in
    # source text never reaches display. _menu_lines set dynamically after
    # initial render based on actual rendered line count.
    local _menu_lines=0

    # Redirect stdin from terminal — _request_ui may be called in a
    # command substitution where stdin is a pipe; _input_read_key needs TTY.
    exec 5<&0 2>/dev/null || true
    exec 0</dev/tty 2>/dev/null || true

    # SIGWINCH handler — update TERM_WIDTH so re-renders use correct width.
    # Full re-render happens on next user action (arrow key, etc.).
    trap 'TERM_WIDTH=$(detect_term_width 2>/dev/null || echo 80)' WINCH

    # Separator from prior content, clear below (preserves history above)
    printf '\r\n\033[J' > /dev/tty

    # Initial render
    _request_ui_render "$_prompt" "$_context" _opt_labels "$_opt_count" "$_selected"
    _menu_lines=$_REQ_LINE_COUNT

    # Event loop — reuses shared _input_read_key() for normalized key names
    while :; do
        _input_read_key || { _selected=-1; break; }
        _key="$_INPUT_KEY_RET"

        case "$_key" in
            UP)
                (( _selected > 0 )) && _selected=$((_selected - 1))
                printf '\033[%dA\033[J' "$_menu_lines" > /dev/tty
                _request_ui_render "$_prompt" "$_context" _opt_labels "$_opt_count" "$_selected"
                ;;
            DOWN)
                (( _selected < _opt_count - 1 )) && _selected=$((_selected + 1))
                printf '\033[%dA\033[J' "$_menu_lines" > /dev/tty
                _request_ui_render "$_prompt" "$_context" _opt_labels "$_opt_count" "$_selected"
                ;;
            ENTER)
                break
                ;;
            BACKSPACE|q|Q|C-d)
                _selected=-1
                break
                ;;
        esac
    done

    # Restore original stdin, remove SIGWINCH handler
    trap - WINCH 2>/dev/null || true
    exec 0<&5 5<&- 2>/dev/null || true

    # Clear menu area
    printf '\033[%dA\033[J' "$_menu_lines" > /dev/tty
    printf '\033[1A\033[K' > /dev/tty

    if (( _selected < 0 )); then
        printf '%s  cancelled%s\n' "$LIGHT_YELLOW" "$RESET" > /dev/tty
        printf 'User cancelled the request.\n'
        return 0
    fi

    # Echo selected choice in tree-yellow for UI continuity
    printf '%s  %s%s\n' "$LIGHT_YELLOW" "${_opt_labels[$_selected]:-ok}" "$RESET" > /dev/tty

    _request_resolve "$_selected" _opt_labels
}

# ── Format the selected option as JSON tool output ──
_request_resolve() {
    local _selected="$1" _opt_labels_name="$2"
    local -n _opts="$_opt_labels_name"
    local _choice="${_opts[$_selected]:-unknown}"
    local _idx=$_selected

    jq -nc \
        --arg choice "$_choice" \
        --argjson idx "$_idx" \
        --argjson total "${#_opts[@]}" \
        '{
            result: "selected",
            choice: $choice,
            choice_index: $idx,
            total_options: $total
        }'
}

# ── Daemon async path: emit request_pending frame, write file, return pending ──
_request_async() {
    local _prompt="$1" _options_json="$2" _context="$3" _req_type="${4:-oversight}"
    local _dir _rid _resp_file=""

    _rid="req_${EPOCHSECONDS:-$(date +%s)}_$$_$RANDOM"
    _dir="${BASHAGT_PROJECT_DIR:-$HOME/.bashagt/sessions/_oneshot_$$}"
    mkdir -p "$_dir" 2>/dev/null || _dir=$(_mktemp_dir /tmp/bashagt_req_XXXXXX 2>/dev/null || echo "${BASHAGT_TMPDIR:-/tmp}")

    # Oneshot (non-daemon) stream mode: create response file for hotkey
    # to write the user's choice into. Bashagt polls this file in run_turn.
    if (( ${BASHAGT_STREAM_MODE:-0} )) && [[ "${BASHAGT_DAEMON_WORKER:-0}" != "1" ]]; then
        _resp_file=$(_mktemp_u /tmp/bashagt_resp_XXXXXX)
        _RESPONSE_FILE="$_resp_file"
    fi

    # Write pending request to session dir (with plan data if plan_confirm)
    if [[ "$_req_type" == "plan_confirm" ]]; then
        jq -nc \
            --arg id "$_rid" \
            --arg prompt "$_prompt" \
            --argjson options "$_options_json" \
            --arg context "$_context" \
            --arg req_type "$_req_type" \
            --arg plan_file "${BASHAGT_PROJECT_DIR:-$PWD}/.bashagt/plan.md" \
            --argjson ts "$(_timestamp_ms)" \
            '{
                request_id: $id,
                prompt: $prompt,
                options: $options,
                context: $context,
                request_type: $req_type,
                plan_file: $plan_file,
                created_at: $ts,
                status: "pending"
            }' > "$_dir/request_pending.json"
    else
        jq -nc \
            --arg id "$_rid" \
            --arg prompt "$_prompt" \
            --argjson options "$_options_json" \
            --arg context "$_context" \
            --arg req_type "$_req_type" \
            --argjson ts "$(_timestamp_ms)" \
            '{
                request_id: $id,
                prompt: $prompt,
                options: $options,
                context: $context,
                request_type: $req_type,
                created_at: $ts,
                status: "pending"
            }' > "$_dir/request_pending.json"
    fi

    # Emit JSONL frame for SSE clients
    # Must use fd 8 (bypass) — stdout is redirected to tool temp file
    local _has_fd8=0
    [[ -e /proc/self/fd/8 ]] || [[ -e /dev/fd/8 ]] && _has_fd8=1

    if (( _has_fd8 )); then
        local _payload
        _payload=$(jq -nc \
            --arg id "$_rid" \
            --arg prompt "$_prompt" \
            --argjson options "$_options_json" \
            --arg context "$_context" \
            --arg resp_file "$_resp_file" \
            --arg req_type "$_req_type" \
            '{
                request_id: $id,
                prompt: $prompt,
                options: $options,
                context: $context,
                response_file: $resp_file,
                request_type: $req_type
            }')
        _stream_emit "request_pending" "$_payload" >&8
    else
        _stream_emit "request_pending" "$(jq -nc \
            --arg id "$_rid" \
            --arg prompt "$_prompt" \
            --argjson options "$_options_json" \
            --arg context "$_context" \
            --arg resp_file "$_resp_file" \
            --arg req_type "$_req_type" \
            '{request_id:$id,prompt:$prompt,options:$options,context:$context,response_file:$resp_file,request_type:$req_type}')"
    fi

    # Signal that this turn should end after the tool result is added
    # (daemon path: force return; oneshot path: polls _RESPONSE_FILE)
    _REQUEST_PENDING=1

    # Return pending status — model sees this as tool_result
    jq -nc \
        --arg id "$_rid" \
        '{
            result: "pending",
            request_id: $id,
            message: "Human input requested. The response will arrive in the next turn."
        }'
}

# ── Router: detect mode and delegate ──
tool_request() {
    local _prompt="$1" _options_json="$2" _context="$3"

    if _request_detect_mode; then
        # Interactive / hotkey: blocking TUI on /dev/tty
        _request_ui "$_prompt" "$_options_json" "$_context"
    else
        # Daemon / async: non-blocking, emit JSONL frame
        _request_async "$_prompt" "$_options_json" "$_context"
    fi
}

# ============================================================================
# SECTION 11: Agent Loop
# ============================================================================

# Extract tool_use blocks from content array

# Extract text blocks from content array (for display only, already streamed)

# Session token accumulator
SESSION_INPUT_TOKENS=0
SESSION_OUTPUT_TOKENS=0
PLAN_LAST_FORMATTED=""
# Plan confirmation state (unified agent("plan") flow)
_BASHAGT_MD_MTIME=0     # mtime of last-loaded .bashagt/BASHAGT.md
_SKILL_DIR_MTIME=0      # max mtime of ~/.bashagt/skills/ and .bashagt/skills/

# Per-turn format reminder — injected into messages[0] every turn.
# The static §7 rules are easily skimmed in the 290-line system prompt;
# a short reminder in dynamic context keeps formatting rules in attention.
_PLAN_PENDING=0
_PLAN_DEFERRED_MSG=""  # buffered by _plan_handle_response, flushed after tool_result
_DEFERRED_FEEDBACK=""  # deferred text from _plan_auto_todo, emitted after tool_end
_STEP_BANNER=""        # set by tool_task_update in_progress, emitted after tool_end
_PLAN_STOP=0           # set by _plan_handle_response on reject/cancel → end turn
TURN_COUNTER=0
NON_PRODUCTIVE_STREAK=0
_HOOK_INTERRUPT_FIRED=0
LAST_COMPRESS_TURN=-10  # cooldown: skip auto-compress within 5 turns

# API call globals (set by call_api_nonstreaming)
CONTENT_BLOCKS='[]'
STOP_REASON='end_turn'

# Turn budget tracking (reset per turn)
TURN_TOKENS_USED=0
TURN_PREV_STOP=""
EFFECTIVE_MAX_TOKENS=""
ESTIMATED_CONTEXT_TOKENS=0

# ── Slash command handlers ──
# Extracted verbatim from the original case block in run_turn().
# Each handler receives the full trimmed input as $1.
# /plan with arg sets _SLASH_FALLTHROUGH=1 to continue into the agent loop.
_slash_plan() {
    local trimmed="$1"
    local plan_task="${trimmed#/plan }"
    [[ "$plan_task" == "$trimmed" ]] && plan_task=""
    if [[ -n "$plan_task" ]]; then
        user_input="Call agent(\"plan\", \"design a plan for: $plan_task\")"
        _SLASH_FALLTHROUGH=1
    else
        local _state; _state=$(_plan_state "${BASHAGT_PROJECT_DIR:-$PWD}" 2>/dev/null) || _state="idle"
        case "$_state" in
            active)
                local _done _total
                _done=$(echo "$TODOS" | jq '[.[] | select(.source=="plan" and .status=="completed")] | length' 2>/dev/null || echo 0)
                _total=$(echo "$TODOS" | jq '[.[] | select(.source=="plan")] | length' 2>/dev/null || echo 0)
                _stream_emit "text" "$(jq -nc --arg c \
                    "  ${BOLD}Plan:${RESET} .bashagt/plan.md ($_done/$_total steps done)" '{content: $c}')"
                ;;
            *) _stream_emit "text" "$(jq -nc --arg c \
                    '  Type /plan <task> to create a plan. Example: /plan add dark mode' '{content: $c}')" ;;
        esac
    fi
}
_slash_clear() {
    msg_replace_all '[]'
    save_history
    _stream_emit "text" "$(jq -nc --arg c '  History cleared.' '{content: $c}')"
}
_slash_save() {
    save_history
    _stream_emit "text" "$(jq -nc --arg c "  History saved to $BASHAGT_HISTORY_FILE" '{content: $c}')"
}
_slash_load() { load_history; }
_slash_compress() { compress_context; save_history; }
_slash_model() {
    local trimmed="$1"
    local _new_profile="${trimmed#/model }"
    if [[ "$_new_profile" == "$trimmed" ]] || [[ -z "$_new_profile" ]]; then
        local _mout="  ${BOLD}Profile:${RESET} $(_prof_get_field name) → $(_prof_get_field model)"$'\n'
        _mout+="  ${BOLD}Endpoint:${RESET} $(_prof_get_field api_url)"$'\n'
        if [[ ${#MODEL_PROFILES[@]} -gt 0 ]]; then
            _mout+="  ${BOLD}Available profiles:${RESET}"$'\n'
            _mout+="    default  (flat config: $BASHAGT_MODEL)"$'\n'
            local _pn _pm
            for _pn in "${!MODEL_PROFILES[@]}"; do
                _pm=$(echo "${MODEL_PROFILES[$_pn]}" | jq -r '.model // "(inherited)"' 2>/dev/null)
                _mout+="    $_pn → $_pm"$'\n'
            done
        fi
        if [[ -f "$HOME/.bashagt/model_pool.json" ]]; then
            local _mp_count; _mp_count=$(jq '.models | length' "$HOME/.bashagt/model_pool.json" 2>/dev/null || echo 0)
            _mout+="  ${BOLD}Model pool:${RESET} $_mp_count models ($HOME/.bashagt/model_pool.json)"
        fi
        _stream_emit "text" "$(jq -nc --arg c "$_mout" '{content: $c}')"
    else
        if [[ "$_new_profile" == "default" ]]; then
            BASHAGT_MAIN_PROFILE=""
            _resolve_profile "" || true
            _stream_emit "text" "$(jq -nc --arg c "  Switched to default profile: $BASHAGT_MODEL" '{content: $c}')"
        elif _resolve_profile "$_new_profile"; then
            BASHAGT_MAIN_PROFILE="$_new_profile"
            _stream_emit "text" "$(jq -nc --arg p "$_new_profile" --arg m "$(_prof_get_field model)" '{content: ("  Switched to profile: \($p) → \($m)")}')"
        else
            _stream_emit "text" "$(jq -nc --arg p "$_new_profile" '{content: ("  Profile not found: \($p). Use /model to list available profiles.")}')"
        fi
    fi
}
_slash_status() {
    local msg_count size
    msg_count=$(jq 'length' <<< "$MESSAGES")
    size=${#MESSAGES}
    local _out
    printf -v _out '%s\n' \
        "  ${BOLD}Profile:${RESET} $(_prof_get_field name) → $(_prof_get_field model)" \
        "  ${BOLD}Endpoint:${RESET} $(_prof_get_field api_url)" \
        "  ${BOLD}Model:${RESET} $BASHAGT_MODEL" \
        "  ${BOLD}API:${RESET}  $BASHAGT_API_URL" \
        "  ${BOLD}Messages:${RESET} $msg_count  ($size bytes)" \
        "  ${BOLD}Tokens in/out:${RESET} $SESSION_INPUT_TOKENS / $SESSION_OUTPUT_TOKENS" \
        "  ${BOLD}History:${RESET} $BASHAGT_HISTORY_FILE"
    local _tcnt _tdone _tip
    _tcnt=$(echo "$TODOS" | jq 'length')
    _tdone=$(echo "$TODOS" | jq '[.[] | select(.status == "completed")] | length')
    _tip=$(echo "$TODOS" | jq '[.[] | select(.status == "in_progress")] | length')
    (( _tcnt > 0 )) && _out+=$'\n'"  ${BOLD}Tasks:${RESET}    $_tcnt total / $(ui_label "$_tdone done" green) / $(ui_label "$_tip in progress" cyan)"
    ((${#ACTIVE_SKILLS[@]} > 0)) && _out+=$'\n'"  ${BOLD}Skills:${RESET}  ${ACTIVE_SKILLS[*]}"
    if [[ "${BASHAGT_MCP_ENABLED:-true}" == "true" ]] && ((${#MCP_SERVERS[@]} > 0)); then
        _out+=$'\n'"  ${BOLD}MCP:${RESET}     ${MCP_CONNECTED_COUNT:-0}/${#MCP_SERVERS[@]} server(s) connected"
        local _mn
        for _mn in "${!MCP_SERVER_READY[@]}"; do
            [[ "${MCP_SERVER_READY[$_mn]}" == "1" ]] || continue
            local _mtc; _mtc=$(jq 'length' <<< "${MCP_SERVER_TOOLS[$_mn]:-[]}" 2>/dev/null || echo 0)
            _out+=$'\n'"    $(ui_dot done) $_mn ($_mtc tools)"
        done
    fi
    _stream_emit "text" "$(jq -nc --arg c "$_out" '{content: $c}')"
}
_slash_skills() {
    load_skills
    local _out=''
    _out+=$(ui_label 'AVAILABLE SKILLS' bold)$'\n'
    for name in "${!SKILLS[@]}"; do
        local desc; desc=$(echo "${SKILL_META[$name]}" | jq -r '.description // ""' 2>/dev/null)
        local tag=""
        [[ " ${ACTIVE_SKILLS[*]} " =~ " ${name} " ]] || tag=$(ui_label 'off' dim)
        _out+=$(printf "  %-20s %s %s" "$name" "$desc" "$tag")$'\n'
    done
    _out+=$'\n'"  Commands: /skill <name>  /skill-off <name>"$'\n'
    _stream_emit "text" "$(jq -nc --arg c "$_out" '{content: $c}')"
}
_slash_skill() {
    local trimmed="$1"
    local sname="${trimmed#/skill }"
    activate_skill "$sname"
}
_slash_skill_off() {
    local trimmed="$1"
    local sname="${trimmed#/skill-off }"
    deactivate_skill "$sname"
}
_slash_memory() {
    load_memories
    _mem_refresh_cache
    local _mem_out=''
    _mem_out+=$(ui_label 'MEMORY NETWORK' bold)$'\n'
    local _slot_info
    _slot_info=$(jq -r '.global | "\(.total_capacity // '"$MEM_TOTAL_CAPACITY"')\t\(.total_used // 0)\t\(.write_rate // 0)\t\(.total_free // 0)\t\(.sleep_phase // false)\t\(.last_sleep_phase // 0)"' "$MEM_SLOT_TABLE" 2>/dev/null)
    IFS=$'\t' read -r _total _used _rate _free _in_sleep _lsp <<< "${_slot_info:-$MEM_TOTAL_CAPACITY	0	0	0	false	0}"
    _mem_out+=$(printf '  Capacity: %d slots (%d engrams × %d)' "$_total" "$MEM_ENGRAM_COUNT" "$MEM_ENGRAM_SLOTS")$'\n'
    _mem_out+=$(printf '  Used:     %d (%.1f%%)' "$_used" "$(awk "BEGIN { printf \"%.1f\", $_rate * 100 }" 2>/dev/null || echo 0)")$'\n'
    _mem_out+=$(printf '  Free:     %d' "$_free")$'\n'
    if [[ -n "$MEMORY_POOL" ]] && [[ "$MEMORY_POOL" != "[]" ]]; then
        local _type_counts _nm _nc _na
        _type_counts=$(printf '%s' "$MEMORY_POOL" | jq -r '
          { manual:   ([.[] | select(.src == "manual")] | length),
            compress: ([.[] | select(.src == "compress")] | length),
            auto:     ([.[] | select(.src == "auto")] | length)
          } | "\(.manual)\t\(.compress)\t\(.auto)"
        ' 2>/dev/null) || _type_counts=$'0\t0\t0'
        IFS=$'\t' read -r _nm _nc _na <<< "$_type_counts"
        _mem_out+=$(printf '  Manual: %d | Compress: %d | Auto: %d' "$_nm" "$_nc" "$_na")$'\n'
    fi
    if [[ "$_in_sleep" == "true" ]]; then
        _mem_out+=$(ui_label 'Sleep phase: IN PROGRESS' yellow)$'\n'
    elif (( _lsp > 0 )); then
        _mem_out+=$(printf '  Last sleep phase: %s (rate: %.1f%%)' \
            "$(_date_from_epoch "$_lsp" '+%Y-%m-%d %H:%M')" \
            "$(awk "BEGIN { printf \"%.1f\", $_rate * 100 }" 2>/dev/null || echo 0)")$'\n'
    fi
    if [[ -n "$MEMORY_POOL" ]] && [[ "$MEMORY_POOL" != "[]" ]]; then
        _mem_out+=$'\n'"  Recent memories (top 10 by access×importance):"$'\n'
        _mem_out+=$(printf '%s' "$MEMORY_POOL" | jq -r 'sort_by(-(.n * .i)) | .[0:10] | .[] | "  - [`\(.id)`] \(.s // \"?\") (×\(.n // 0))"' 2>/dev/null)$'\n'
    else
        _mem_out+=$'\n'"  No memories stored."$'\n'
    fi
    _mem_out+=$'\n'"  Use /remember <text> to save a memory."$'\n'
    _stream_emit "text" "$(jq -nc --arg c "$_mem_out" '{content: $c}')"
}
_slash_remember() {
    local trimmed="$1"
    local mem_text="${trimmed#/remember }"
    if [[ "$mem_text" == "$trimmed" ]] || [[ -z "$mem_text" ]]; then
        _stream_emit "text" "$(jq -nc --arg c '  Usage: /remember <text to remember>' '{content: $c}')"
        return
    fi
    _stream_emit "text" "$(jq -nc --arg c "$(ui_label 'Writing to memory network...' gray)" '{content: $c}')"
    local wr_rate; wr_rate=$(_mem_write_rate)
    if awk "BEGIN { exit ($wr_rate >= 0.85 ? 0 : 1) }" 2>/dev/null; then
        _stream_emit "text" "$(jq -nc --arg c "$(ui_label 'Memory network in sleep phase — write queued' yellow)" '{content: $c}')"
    fi
    local _target_engram
    _target_engram=$(call_agent "mem_writer" "Save this memory. Source: manual.
Text: $mem_text" 2>/dev/null | grep -oE 'engram_[0-9]{2}' || echo "")
    if [[ -n "$_target_engram" ]]; then
        _mem_dispatch_inbox "$_target_engram"
    fi
    _mem_refresh_cache
}
_slash_tasks() { list_tasks; }
_slash_task_cancel() {
    local trimmed="$1"
    local tid="${trimmed#/task-cancel }"
    task_cancel "$tid"
}
_slash_todo() { todo_list; }
_slash_todo_add() {
    local trimmed="$1"
    local tsubject="${trimmed#/todo-add }"
    if [[ "$tsubject" == "$trimmed" ]] || [[ -z "$tsubject" ]]; then
        _stream_emit "text" "$(jq -nc --arg c '  Usage: /todo-add <task title>' '{content: $c}')"
        return
    fi
    local tid; tid=$(todo_add "$tsubject" "" "manual")
    _stream_emit "text" "$(jq -nc --arg c "$(ui_label '✓' green) Task added: [$(echo "$TODOS" | jq 'length')] $tsubject" '{content: $c}')"
}
_slash_todo_done() {
    local trimmed="$1"
    local targ="${trimmed#/todo-done }"
    if [[ "$targ" == "$trimmed" ]] || [[ -z "$targ" ]]; then
        _stream_emit "text" "$(jq -nc --arg c '  Usage: /todo-done <id or index>' '{content: $c}')"
        return
    fi
    local tid="$targ"
    if [[ "$targ" =~ ^[0-9]+$ ]]; then
        tid=$(todo_find_by_index "$targ")
        if [[ -z "$tid" ]]; then
            _stream_emit "text" "$(jq -nc --arg c "  Invalid index: $targ" '{content: $c}')"
            return
        fi
    fi
    if todo_update "$tid" "completed"; then
        local subj; subj=$(echo "$TODOS" | jq -r --arg id "$tid" '.[] | select(.id == $id) | .subject')
        _stream_emit "text" "$(jq -nc --arg c "$(ui_label '✓' green) Done: $subj" '{content: $c}')"
    fi
}
_slash_todo_fail() {
    local trimmed="$1"
    local targ="${trimmed#/todo-fail }"
    if [[ "$targ" == "$trimmed" ]] || [[ -z "$targ" ]]; then
        _stream_emit "text" "$(jq -nc --arg c '  Usage: /todo-fail <id or index>' '{content: $c}')"
        return
    fi
    local tid="$targ"
    if [[ "$targ" =~ ^[0-9]+$ ]]; then
        tid=$(todo_find_by_index "$targ")
        if [[ -z "$tid" ]]; then
            _stream_emit "text" "$(jq -nc --arg c "  Invalid index: $targ" '{content: $c}')"
            return
        fi
    fi
    if todo_update "$tid" "failed"; then
        local subj; subj=$(echo "$TODOS" | jq -r --arg id "$tid" '.[] | select(.id == $id) | .subject')
        _stream_emit "text" "$(jq -nc --arg c "$(ui_label '✗' red) Failed: $subj" '{content: $c}')"
    fi
}
_slash_todo_start() {
    local trimmed="$1"
    local targ="${trimmed#/todo-start }"
    if [[ "$targ" == "$trimmed" ]] || [[ -z "$targ" ]]; then
        _stream_emit "text" "$(jq -nc --arg c '  Usage: /todo-start <id or index>' '{content: $c}')"
        return
    fi
    local tid="$targ"
    if [[ "$targ" =~ ^[0-9]+$ ]]; then
        tid=$(todo_find_by_index "$targ")
        if [[ -z "$tid" ]]; then
            _stream_emit "text" "$(jq -nc --arg c "  Invalid index: $targ" '{content: $c}')"
            return
        fi
    fi
    if todo_update "$tid" "in_progress"; then
        local subj; subj=$(echo "$TODOS" | jq -r --arg id "$tid" '.[] | select(.id == $id) | .subject')
        _stream_emit "text" "$(jq -nc --arg c "$(ui_label '▶' cyan) Started: $subj" '{content: $c}')"
    fi
}
_slash_todo_delete() {
    local trimmed="$1"
    local targ="${trimmed#/todo-delete }"
    if [[ "$targ" == "$trimmed" ]] || [[ -z "$targ" ]]; then
        _stream_emit "text" "$(jq -nc --arg c '  Usage: /todo-delete <id or index>' '{content: $c}')"
        return
    fi
    local tid="$targ"
    if [[ "$targ" =~ ^[0-9]+$ ]]; then
        tid=$(todo_find_by_index "$targ")
        if [[ -z "$tid" ]]; then
            _stream_emit "text" "$(jq -nc --arg c "  Invalid index: $targ" '{content: $c}')"
            return
        fi
    fi
    local subj; subj=$(echo "$TODOS" | jq -r --arg id "$tid" '.[] | select(.id == $id) | .subject')
    if todo_delete "$tid"; then
        _stream_emit "text" "$(jq -nc --arg c "$(ui_label '✗' red) Deleted: $subj" '{content: $c}')"
    fi
}
_slash_todo_edit() {
    local trimmed="$1"
    local targ="${trimmed#/todo-edit }"
    if [[ "$targ" == "$trimmed" ]] || [[ -z "$targ" ]]; then
        _stream_emit "text" "$(jq -nc --arg c '  Usage: /todo-edit <id or index> <new title>' '{content: $c}')"
        return
    fi
    local first rest
    first="${targ%% *}"
    rest="${targ#* }"
    if [[ "$rest" == "$targ" ]] || [[ -z "$rest" ]]; then
        _stream_emit "text" "$(jq -nc --arg c '  Usage: /todo-edit <id or index> <new title>' '{content: $c}')"
        return
    fi
    local tid="$first"
    if [[ "$first" =~ ^[0-9]+$ ]]; then
        tid=$(todo_find_by_index "$first")
        if [[ -z "$tid" ]]; then
            _stream_emit "text" "$(jq -nc --arg c "  Invalid index: $first" '{content: $c}')"
            return
        fi
    fi
    if todo_update "$tid" "" "$rest"; then
        _stream_emit "text" "$(jq -nc --arg c "$(ui_label '✎' yellow) Updated: $rest" '{content: $c}')"
    fi
}
_slash_mcp() {
    local trimmed="$1"
    local _mcp_cmd="${trimmed#/mcp }"
    if [[ "$_mcp_cmd" == "$trimmed" ]]; then
        cmd_mcp_status
    else
        case "$_mcp_cmd" in
            list|list\ *)        cmd_mcp_list ;;
            connect\ *)          cmd_mcp_connect "${_mcp_cmd#connect }" ;;
            disconnect\ *)       cmd_mcp_disconnect "${_mcp_cmd#disconnect }" ;;
            refresh|refresh\ *)  cmd_mcp_refresh ;;
            tools|tools\ *)      cmd_mcp_tools "${_mcp_cmd#tools }" ;;
            *) _stream_emit "text" "$(jq -nc --arg c "  Unknown /mcp command: $_mcp_cmd"$'\n'"  Try: /mcp [list|connect|disconnect|refresh|tools]" '{content: $c}')" ;;
        esac
    fi
}
_slash_safe() {
    local _trimmed="$1" _args="${_trimmed#/safe }"
    [[ "$_args" == "$_trimmed" ]] && _args=""

    case "$_args" in
        on)   BASHAGT_SAFE_MODE=1 ;;
        off)  BASHAGT_SAFE_MODE=0 ;;
        "")
            if [[ "${BASHAGT_SAFE_MODE:-0}" == "1" ]]; then
                BASHAGT_SAFE_MODE=0
            else
                BASHAGT_SAFE_MODE=1
            fi
            ;;
        *)  _stream_emit "warning" "$(jq -nc '{content: "  Usage: /safe [on|off]"}')"; return ;;
    esac

    _safe_update_prompt "$BASHAGT_SAFE_MODE"

    if [[ "${BASHAGT_SAFE_MODE:-0}" == "1" ]]; then
        _stream_emit "text" "$(jq -nc --arg y "$LIGHT_YELLOW" --arg r "$RESET" '{content: ($y + "◆ Safe mode ON" + $r + " — destructive tools require confirmation. Shift+Tab to toggle.")}')"
    else
        _stream_emit "text" "$(jq -nc --arg c "$CYAN" --arg r "$RESET" '{content: ($c + "◆ Safe mode OFF" + $r + " — tools execute directly. Shift+Tab to toggle.")}')"
    fi
}
_slash_dark() {
    local _trimmed="$1" _args="${_trimmed#/dark }"
    [[ "$_args" == "$_trimmed" ]] && _args=""

    case "$_args" in
        on|true|1)   BASHAGT_DARK_MODE="true" ;;
        off|false|0) BASHAGT_DARK_MODE="false" ;;
        "")
            if [[ "${BASHAGT_DARK_MODE:-true}" != "false" && "${BASHAGT_DARK_MODE:-true}" != "0" ]]; then
                BASHAGT_DARK_MODE="false"
            else
                BASHAGT_DARK_MODE="true"
            fi
            ;;
        *)  _stream_emit "warning" "$(jq -nc '{content: "  Usage: /dark [on|off]"}')"; return ;;
    esac

    _colors_resolve

    local _dim="$DIM" _rst="$RESET"
    if [[ "${BASHAGT_DARK_MODE:-true}" != "false" && "${BASHAGT_DARK_MODE:-true}" != "0" ]]; then
        _stream_emit "text" "$(jq -nc --arg d "$_dim" --arg r "$_rst" '{content: ($d + "◆ Dark mode ON" + $r)}')"
    else
        _stream_emit "text" "$(jq -nc --arg d "$_dim" --arg r "$_rst" '{content: ($d + "◆ Dark mode OFF (Light)" + $r)}')"
    fi
}
_slash_help() {
    local _help_out
    printf -v _help_out '%s\n' \
        "  ${BOLD}SESSION${RESET}" \
        "    /plan            Design an implementation plan (confirmed via request UI)" \
        "    /clear           Clear conversation history" \
        "    /status          Show session statistics" \
        "    /model [profile] Show or switch model profile" \
        "    /mcp [list|...]  Manage MCP servers" \
        "    /compress        Trigger context compression" \
        "    /exit            Exit bashagt" \
        "  ${BOLD}TRACE${RESET}" \
        "    /trace [log|show|diff|status|snapshot]  View modification history" \
        "    /undo [N]        Undo recent file modifications" \
        "  ${BOLD}SKILLS${RESET}" \
        "    /skills          List available skills" \
        "    /skill <name>    Activate a skill" \
        "    /skill-off <name> Deactivate a skill" \
        "  ${BOLD}MEMORY${RESET}" \
        "    /memory          Show memory network" \
        "    /remember <text> Save a persistent memory" \
        "  ${BOLD}TASKS${RESET}" \
        "    /tasks           List background tasks" \
        "    /task-cancel <id> Cancel a background task" \
        "  ${BOLD}TODO${RESET}" \
        "    /todo            Show task list with progress" \
        "    /todo-add <title> Add a task" \
        "    /todo-start ...  Start a task (by index or id)" \
        "    /todo-done ...   Mark task done (by index or id)" \
        "    /todo-fail ...   Mark task failed (by index or id)" \
        "    /todo-edit ...   Edit a task title" \
        "    /todo-delete ... Delete a task"
    _stream_emit "text" "$(jq -nc --arg c "$_help_out" '{content: $c}')"
}
_slash_exit() {
    _input_cleanup
    printf '\n'
    print_session_summary
    save_history
    exit 0
}

# Register all slash commands
_register_slash plan       _slash_plan
_register_slash clear      _slash_clear
_register_slash save       _slash_save
_register_slash load       _slash_load
_register_slash compress   _slash_compress
_register_slash model      _slash_model
_register_slash status     _slash_status
_register_slash skills     _slash_skills
_register_slash skill      _slash_skill
_register_slash skill-off  _slash_skill_off
_register_slash memory     _slash_memory
_register_slash remember   _slash_remember
_register_slash tasks      _slash_tasks
_register_slash task-cancel _slash_task_cancel
_register_slash todo       _slash_todo
_register_slash todo-add   _slash_todo_add
_register_slash todo-start _slash_todo_start
_register_slash todo-done  _slash_todo_done
_register_slash todo-fail  _slash_todo_fail
_register_slash todo-delete _slash_todo_delete
_register_slash todo-edit  _slash_todo_edit
_register_slash mcp        _slash_mcp
_register_slash trace      _slash_trace
_register_slash undo       _slash_undo
_register_slash help       _slash_help
_register_slash exit       _slash_exit
_register_slash quit       _slash_exit
_register_slash safe       _slash_safe
_register_slash dark       _slash_dark

# ── Behavioral rules reminder (every turn, ephemeral — not persisted to history) ──
_RULES_REMINDER_TEXT='1.  Confirm explicit edit request before modifying. Use request() if uncertain.
2.  Delegate exploration to explore agent, not blind read_file on large projects.
3.  Use edit_file for existing files, write_file for new. Read first, match byte-for-byte. Never sed/awk.
4.  Reproduce bugs before fixing. First diagnosis is often wrong — verify root cause.
5.  Multi-step work needs TODOs first. Call make_todos() after planning, task_update() to track progress.
6.  High-risk operations need request() confirmation. Assess blast radius first.
7.  Never use cat/head/tail/ls as substitute for read_file/list_files.
8.  Delegate complex work to agents. Use agent_batch for parallel independent tasks.
9.  Read existing code before proposing changes. Do not assume — verify with read_file.
10. Destructive operations (rm -rf, force-push, hard reset) require user approval.
11. Test changes end-to-end before claiming success. Check golden path and edge cases.
12. Save important decisions and preferences to memory. Use /remember for persistence.
13. Design the approach first. For complex tasks, delegate to plan agent.
14. Be concise. Start with the answer — skip greetings, filler, and sign-offs.
15. If a tool fails twice, report the error. Do not retry blindly.'

# ── Deferred data flush helpers ──
# Called by run_turn to consolidate scattered _DEFERRED_* reads into two sites.

# Flush deferred tool feedback + step banner (set by tool_make_todos / tool_task_update).
# Called immediately after dispatch_tool returns — before any branching.
_turn_flush_feedback() {
    if [[ -n "${_DEFERRED_FEEDBACK:-}" ]]; then
        _stream_emit "text" "$(jq -nc --arg c "$_DEFERRED_FEEDBACK" '{content: $c}')"
        _DEFERRED_FEEDBACK=""
    fi
    if [[ -n "${_STEP_BANNER:-}" ]]; then
        _stream_emit "text" "$(jq -nc --arg c "$_STEP_BANNER" '{content: $c}')"
        _STEP_BANNER=""
    fi
}

# Flush deferred assistant content, plan messages, and plan stop.
# Must be called AFTER tool_results are added (preserving tool_use/tool_result pairing).
# Returns 1 if _PLAN_STOP was set (caller should end the turn).
_turn_flush_assistant() {
    # Flush plan deferred message BEFORE assistant content so it appends to
    # the previous user message, not the assistant message. Prevents text
    # block being inserted after tool_use blocks (API rejects that ordering).
    if [[ -n "${_PLAN_DEFERRED_MSG:-}" ]]; then
        local _pjson; _pjson=$(jq -nc --arg text "$_PLAN_DEFERRED_MSG" '{type:"text","text":$text}')
        MESSAGES=$(jq -c --argjson block "$_pjson" \
            '.[-1].content += [$block]' <<< "$MESSAGES")
        _cc_invalidate msgs
        _PLAN_DEFERRED_MSG=""
    fi
    if [[ -n "${_DEFERRED_CONTENT_JSON:-}" ]]; then
        msg_add_assistant "$_DEFERRED_CONTENT_JSON"
        _DEFERRED_CONTENT_JSON=""
    fi
    if (( ${_PLAN_STOP:-0} )); then
        _PLAN_STOP=0
        return 1
    fi
    return 0
}

_rules_reminder_inject() {
    _HOOK_CONTEXT_BUFFER+=$'\n\n'"--- RULES REMINDER ---"$'\n'"$_RULES_REMINDER_TEXT"
}

# ── _turn_init — per-turn state reset + input processing ──
_turn_init() {
    _tm "turn_init_start"
    local user_input="$1"
    log "DEBUG: TURN_BEGIN input_size=${#user_input} msgs=$MSG_COUNT"

    # Reset per-turn state
    _REQUEST_PENDING=0
    TURN_TOKENS_USED=0
    TURN_INPUT_ACCUM=0
    TURN_OUTPUT_ACCUM=0
    TURN_PREV_STOP=""
    EFFECTIVE_MAX_TOKENS=""
    TURN_COUNTER=$((TURN_COUNTER + 1))
    _HOOK_CONTEXT_BUFFER=""
    _DEFERRED_FEEDBACK=""
    _STEP_BANNER=""
    _rules_reminder_inject

    # Trim whitespace (POSIX-compliant pure bash, avoids sed fork)
    local _trimmed="$user_input"
    _trimmed="${_trimmed#${_trimmed%%[![:space:]]*}}"
    _trimmed="${_trimmed%${_trimmed##*[![:space:]]}}"

    # ── Plan confirmation marker (daemon gateway injects this) ──
    if [[ "$_trimmed" == __PLAN_APPROVED__* ]]; then
        local _plan_file="${BASHAGT_PROJECT_DIR:-$PWD}/.bashagt/plan.md"
        if [[ -f "$_plan_file" ]]; then
            _stream_emit "text" "$(jq -nc --arg c '  Plan approved. Verify: .bashagt/plan.md' '{content: $c}')"
            msg_add_user_text "Plan approved by user. Plan saved to .bashagt/plan.md.

Before creating TODO items, verify the plan:
1. Read the plan with: read_file(\".bashagt/plan.md\")
2. Check it addresses the user's requirements correctly
3. Verify implementation steps are complete and feasible
4. Identify any missing steps, incorrect assumptions, or risks
5. If issues found, suggest specific modifications now
6. When satisfied, call: make_todos(plan_text) to extract steps and create TODO items"
        fi
        return 1
    fi

    # Dispatch slash commands via registry
    if _slash_dispatch "$_trimmed"; then
        [[ "${_SLASH_FALLTHROUGH:-0}" == "1" ]] || return 1
    fi

    # Add user message to history
    msg_add_user_text "$_trimmed"
    # Return processed input via global (same-shell contract)
    _TURN_INPUT="$_trimmed"
    _tm "turn_init_done"
    return 0
}

run_turn() {
    local user_input="$1" called_task=0

    _turn_init "$user_input" || return
    user_input="${_TURN_INPUT:-$user_input}"

    # ── pre_turn hook — situational context injection ──
    local _hook_ctx _hook_results _hook_item
    estimate_context_tokens
    _hook_ctx=$(jq -nc \
        --argjson ctx_used "$ESTIMATED_CONTEXT_TOKENS" \
        --argjson ctx_limit "$BASHAGT_CONTEXT_WINDOW" \
        --argjson ctx_pct "$(( ESTIMATED_CONTEXT_TOKENS * 100 / BASHAGT_CONTEXT_WINDOW ))" \
        --argjson turn "${TURN_COUNTER:-0}" \
        --argjson streak "${NON_PRODUCTIVE_STREAK:-0}" \
        --argjson active_todos "$(_count_active_todos)" \
        --arg goal "${user_input%%$'\n'*}" \
        '{context:{used:$ctx_used,limit:$ctx_limit,pct:$ctx_pct},turn:{current:$turn,non_productive_streak:$streak},todos:{active:$active_todos},goal:$goal}')
    _hook_results_file=$(_mktemp_file "/tmp/bashagt_hook_result.XXXXXX")
    _hook_fire "pre_turn" "$_hook_ctx" "$_hook_results_file"
    _hook_results=$(<"$_hook_results_file")
    rm -f "$_hook_results_file"
    if [[ "$_hook_results" != "[]" && -n "$_hook_results" ]]; then
        while IFS= read -r _hook_item; do
            [[ -z "$_hook_item" ]] && continue
            local _inj; _inj=$(jq -r '.inject // false' <<< "$_hook_item" 2>/dev/null)
            if [[ "$_inj" == "true" ]]; then
                local _hcontent; _hcontent=$(jq -r '.content // ""' <<< "$_hook_item" 2>/dev/null)
                # Inject into ephemeral buffer (dyn_msg) — NOT into MESSAGES
                # to avoid polluting the prompt cache and message history.
                [[ -n "$_hcontent" ]] && _HOOK_CONTEXT_BUFFER+="$_hcontent"$'\n'
            fi
        done < <(jq -c '.[]' <<< "$_hook_results" 2>/dev/null)
    fi

    # ── Safe mode active notice (dynamic per-turn injection) ──
    if [[ "${BASHAGT_SAFE_MODE:-0}" == "1" ]]; then
        _HOOK_CONTEXT_BUFFER+=$'\n'"--- SAFE MODE ACTIVE ---"$'\n'\
"Safe mode is ON. Destructive tools (write_file, edit_file, delete_file, bash) will be intercepted for user confirmation before execution. "\
"If a tool returns {\"status\":\"denied\"}, this is a definitive user rejection — do NOT retry. "\
"To disable safe mode, the user can press Shift+Tab or type /safe off."$'\n'
    fi

    # Track time
    local turn_start turn_end elapsed_ms
    turn_start=$(_timestamp_ms)

    # Main agent loop for this turn — budget-limited
    local _round=0 _thinking_only_retry=0
    while true; do
        _round=$((_round + 1))
        log "DEBUG: TURN_ROUND round=$_round"

        # Context window guard: warn / compress / stop before API call
        if ! context_window_check; then
            _stream_emit "warning" "$(jq -nc --arg c 'Context window exceeded — turn stopped to prevent data loss.' '{content: $c}')"
            break
        fi

        # Set per-call budget based on phase + remaining capacity
        compute_call_budget "$_round" "$TURN_PREV_STOP"

        # Call API with continuous retry on empty response (1s interval).
        # Stops when: content received, context full, or permanent error.
        local _content_json=""
        while true; do
            # Interrupt check (Esc during previous tool) — skip API call
            if [[ "${_bagt_interrupted:-0}" == "1" ]]; then
                _content_json=""
                break
            fi
            # Context guard: stop retrying if context window is full
            if ! check_turn_budget; then
                _content_json=""
                break
            fi

            # Resolve main profile before each API call
            _resolve_profile "$BASHAGT_MAIN_PROFILE" || _resolve_profile ""
            _tm "run_turn_before_api"
            call_api_nonstreaming
            _tm "run_turn_after_api"
            local api_rc=$?

            # Interrupt check (Esc/Ctrl-C during API call) — must run
            # BEFORE error handling since killed curl returns non-zero.
            if [[ "${_bagt_interrupted:-0}" == "1" ]]; then
                _bagt_interrupted=0
                log "DEBUG: [INT] run_turn think-interrupted — cleared _bagt_interrupted"
                if [[ -n "${_FMT_LINEBUF:-}" ]]; then
                    local _partial; _partial=$(jq -nc --arg text "$_FMT_LINEBUF" '[{type:"text",text:$text}]')
                    msg_add_assistant "$_partial"
                fi
                msg_add_assistant '[{"type":"text","text":"[interrupted]"}]'
                log "DEBUG: TURN_END  stop=interrupted rounds=$_round tok_in=$TURN_INPUT_TOKENS tok_out=$TURN_OUTPUT_TOKENS"
                save_history
                accumulate_turn_tokens
                local _ts_msg; _ts_msg=$(printf '%s %s │ %s %s' \
                    "$(ui_label '──' gray)" \
                    "$(ui_time $(( $(_timestamp_ms) - turn_start )))" \
                    "$(ui_tokens $TURN_INPUT_ACCUM $TURN_OUTPUT_ACCUM)" \
                    "$(ui_label '──' gray)")
                _stream_emit "warning" "$(jq -nc '{content: "Turn interrupted (Esc/Ctrl-C)"}')"
                _stream_emit "info" "$(jq -nc --arg c "$_ts_msg" '{content: $c}')"
                return 0
            fi

            if [[ "$api_rc" -eq 3 ]]; then
                log "API call failed (permanent error), not retrying"
                _content_json=""
                break
            elif [[ "$api_rc" -eq 2 ]]; then
                log "API call failed (mktemp), retrying..."
                sleep 1
                continue
            fi

            _content_json="$CONTENT_BLOCKS"

            # Valid content → proceed
            if [[ -n "$_content_json" ]] && [[ "$_content_json" != "[]" ]] && [[ "$_content_json" != "null" ]]; then
                break
            fi

            # Empty content → wait 1s and retry
            _stream_emit "warning" "$(jq -nc --arg c 'Empty API response, retrying in 1s...' '{content: $c}')"
            sleep 1
        done

        # Interrupt check after retry loop — Esc pressed during tool dispatch
        if [[ "${_bagt_interrupted:-0}" == "1" ]]; then
            _bagt_interrupted=0
            log "DEBUG: [INT] run_turn tool-interrupt — cleared _bagt_interrupted"
            accumulate_turn_tokens
            local _ts_msg; _ts_msg=$(printf '%s %s │ %s %s' \
                "$(ui_label '──' gray)" \
                "$(ui_time $(( $(_timestamp_ms) - turn_start )))" \
                "$(ui_tokens $TURN_INPUT_ACCUM $TURN_OUTPUT_ACCUM)" \
                "$(ui_label '──' gray)")
            _stream_emit "warning" "$(jq -nc '{content: "Turn interrupted (Esc/Ctrl-C)"}')"
            _stream_emit "info" "$(jq -nc --arg c "$_ts_msg" '{content: $c}')"
            log "DEBUG: TURN_END  stop=interrupted rounds=$_round tok_in=$TURN_INPUT_TOKENS tok_out=$TURN_OUTPUT_TOKENS"
            save_history
            return 0
        fi

        local content_json="$_content_json"

        # Guard against persistent empty/invalid content
        if [[ -z "$content_json" ]] || [[ "$content_json" == "[]" ]] || [[ "$content_json" == "null" ]]; then
            log "Warning: persistent empty response, skipping turn"
            log "DEBUG: TURN_END  stop=empty_response rounds=$_round tok_in=$TURN_INPUT_TOKENS tok_out=$TURN_OUTPUT_TOKENS"
            save_history
            return 0
        fi

        # Remove _partial fields before storing (cleanup)
        content_json=$(echo "$content_json" | jq '[.[] | del(._partial)]' 2>/dev/null) || { log "WARN: _partial strip failed — using raw content blocks" >&2; }

        # ── post_response hook — reflection & intervention ──
        local _resp_hook_ctx _resp_hook_results _resp_hook_item
        local _tools_called; _tools_called=$(echo "$content_json" | jq -c '[.[] | select(.type=="tool_use") | .name]' 2>/dev/null)
        _resp_hook_ctx=$(jq -nc \
            --arg stop "${STOP_REASON:-end_turn}" \
            --argjson tools "$_tools_called" \
            --argjson streak "${NON_PRODUCTIVE_STREAK:-0}" \
            --argjson turn "${TURN_COUNTER:-0}" \
            --argjson tok_in "${TURN_INPUT_TOKENS:-0}" \
            --argjson tok_out "${TURN_OUTPUT_TOKENS:-0}" \
            '{stop_reason:$stop,tools_called:$tools,non_productive_streak:$streak,turn:$turn,tokens:{in:$tok_in,out:$tok_out}}')
        _resp_hook_results=$(_hook_fire "post_response" "$_resp_hook_ctx")
        if [[ "$_resp_hook_results" != "[]" && -n "$_resp_hook_results" ]]; then
            while IFS= read -r _resp_hook_item; do
                [[ -z "$_resp_hook_item" ]] && continue
                local _inj_ref; _inj_ref=$(jq -r '.inject_reflection // false' <<< "$_resp_hook_item" 2>/dev/null)
                if [[ "$_inj_ref" == "true" ]]; then
                    local _rprompt; _rprompt=$(jq -r '.reflection_prompt // ""' <<< "$_resp_hook_item" 2>/dev/null)
                    # Inject into ephemeral buffer — appears in NEXT turn's dyn_msg
                    [[ -n "$_rprompt" ]] && _HOOK_CONTEXT_BUFFER+="$_rprompt"$'\n'
                fi
            done < <(jq -c '.[]' <<< "$_resp_hook_results" 2>/dev/null)
        fi

        # Defer msg_add_assistant — must happen AFTER tool dispatch so tools
        # receive the full untruncated content.  Truncation is applied later
        # in the tool_use branch (after all tools have run).
        _DEFERRED_CONTENT_JSON="$content_json"

        # If model returned end_turn but still has pending tool_use blocks,
        # override to tool_use so tools get executed before exiting.
        if [[ "${STOP_REASON:-end_turn}" == "end_turn" ]]; then
            local _pending_tools
            _pending_tools=$(jq '[.[] | select(.type == "tool_use")] | length' <<< "$content_json" 2>/dev/null)
            if (( _pending_tools > 0 )); then
                STOP_REASON="tool_use"
            fi
        fi

        case "${STOP_REASON:-end_turn}" in
            end_turn)
                # If model returned only thinking (no text, no tools), auto-continue
                # once to demand visible output.  Second hit: accept and warn.
                local _vis_text _vis_tools _vis_thinking
                local _vis_batch
                _vis_batch=$(jq -r '
                  ([.[] | select(.type == "text")] | length),
                  ([.[] | select(.type == "tool_use")] | length)
                ' <<< "$content_json" 2>/dev/null; true) || true
                {
                    IFS= read -r _vis_text
                    IFS= read -r _vis_tools
                } <<< "$_vis_batch" || true
                _vis_text="${_vis_text:-0}"
                _vis_tools="${_vis_tools:-0}"
                if (( _vis_text == 0 && _vis_tools == 0 )); then
                    _vis_thinking=$(jq -r '[.[] | select(.type == "thinking") | .thinking] | join("\n")' <<< "$content_json" 2>/dev/null)
                    if [[ -n "$_vis_thinking" ]]; then
                        if (( _thinking_only_retry == 0 )); then
                            _thinking_only_retry=1
                            local _tkw_plain
                            _tkw_plain='⚠ Model produced only internal reasoning. Auto-continuing for visible output...'
                            _stream_emit "warning" "$(jq -nc --arg c "$_tkw_plain" '{content: $c}')"
                            accumulate_turn_tokens
                            TURN_PREV_STOP=""
                            _turn_flush_assistant
                            msg_add_user_text "Your last response contained only internal reasoning with no visible text or tool calls. Please produce visible output — either text or tool calls. Do not end_turn with only thinking."
                            continue
                        fi
                        # Second attempt: accept but warn (thinking was streamed during SSE)
                        local _tw2_plain
                        _tw2_plain='💭 Model finished thinking but produced no text or tools.'
                        _stream_emit "info" "$(jq -nc --arg c "$_tw2_plain" '{content: $c}')"
                    fi
                fi
                # Display turn stats
                turn_end=$(_timestamp_ms)
                if [[ "$turn_start" != "0" ]] && [[ "$turn_end" != "0" ]]; then
                    elapsed_ms=$(( turn_end - turn_start ))
                else
                    elapsed_ms=0
                fi
                accumulate_turn_tokens
                local _ts_msg; _ts_msg=$(printf '%s %s │ %s %s' \
                    "$(ui_label '──' gray)" \
                    "$(ui_time $elapsed_ms)" \
                    "$(ui_tokens $TURN_INPUT_ACCUM $TURN_OUTPUT_ACCUM)" \
                    "$(ui_label '──' gray)")
                _stream_emit "info" "$(jq -nc --arg c "$_ts_msg" '{content: $c}')"
                # Add assistant message now (end_turn: no tools to dispatch)
                _turn_flush_assistant
                log "DEBUG: TURN_END  stop=end_turn rounds=$_round tok_in=$TURN_INPUT_TOKENS tok_out=$TURN_OUTPUT_TOKENS"
                save_history
                return
                ;;

            tool_use)
                # Extract tool_use blocks
                local tool_uses
                tool_uses=$(echo "$content_json" | jq '[.[] | select(.type == "tool_use")]')
                local tool_count
                tool_count=$(echo "$tool_uses" | jq 'length')

                # If request tool is present, only execute request (skip all others)
                if (( tool_count > 0 )); then
                    local _req_count
                    _req_count=$(echo "$tool_uses" | jq '[.[] | select(.name == "request")] | length')
                    if (( _req_count > 0 )); then
                        tool_uses=$(echo "$tool_uses" | jq '[.[] | select(.name == "request")] | .[0:1]')
                        tool_count=1
                        if (( _req_count > 1 )); then
                            _stream_emit "warning" "$(jq -nc --arg c 'Multiple request calls in one turn. Only the first will be processed.' '{content: $c}')"
                        fi
                    fi
                fi

                local results_json='[]'
                local i
                local _TOOL_TREE_ENTRIES="" _TOOL_TREE_COUNT=0 _TOOL_DIFF_CONTENTS=()
                for (( i=0; i<tool_count; i++ )); do
                    local tool_id tool_name tool_input
                    tool_id=$(echo "$tool_uses" | jq -r ".[$i].id")
                    tool_name=$(echo "$tool_uses" | jq -r ".[$i].name")
                    tool_input=$(echo "$tool_uses" | jq ".[$i].input")

                    local _ts_start; _ts_start=$(_timestamp_ms)
                    local tool_output exit_code _tool_out_file _ts_elapsed=0
                    [[ "$tool_name" == "agent" ]] && called_task=1

                    local _tsub_label=""
                    _tsub_label=$(echo "$tool_input" | jq -r '.description // ""' 2>/dev/null)
                    [[ "$_tsub_label" == "null" ]] && _tsub_label=""
                    case "$tool_name" in
                        list_files|read_file|delete_file)
                            local _tpath=""
                            _tpath=$(echo "$tool_input" | jq -r '.path // ""' 2>/dev/null)
                            [[ "$_tpath" == "null" ]] && _tpath=""
                            [[ -n "$_tpath" ]] && _tsub_label="${_tsub_label} ${LIGHT_YELLOW}·${RESET} ${_tpath}"
                            (( ${#_tsub_label} > 144 )) && _tsub_label="${_tsub_label:0:141}..."
                            ;;
                        *)  (( ${#_tsub_label} > 80 )) && _tsub_label="${_tsub_label:0:77}..." ;;
                    esac

                    # ── Safe mode guard (before tool_start — clean cursor model) ──
                    local _safe_skip=0
                    if [[ "${BASHAGT_SAFE_MODE:-0}" == "1" ]]; then
                        case "$tool_name" in
                            write_file|edit_file|delete_file|bash)
                                if ! _safe_confirm "$tool_name" "$tool_input"; then
                                    tool_output=$(jq -nc \
                                        --arg tool "$tool_name" \
                                        '{status: "denied", reason: ("Safe mode: "+$tool+" was denied by user — see §2.1. Do NOT retry.")}')
                                    local _denied_result
                                    _denied_result=$(format_tool_result "$tool_id" "$tool_output" "false")
                                    results_json=$(echo "$results_json" | jq --argjson r "$_denied_result" '. + [$r]')
                                    _SAFE_DENIED=1
                                    break
                                fi
                                ;;
                        esac
                    fi

                    if (( ! _safe_skip )); then
                        # Use temp file to avoid subshell — dispatch_tool modifies globals
                        # (TODOS) that must survive in the parent shell.
                        # Skip tool_start/tool_end for agent/web_search/agent_batch — they have
                        # their own async_spin spinner (now correctly routed via fd 8).
                        case "$tool_name" in
                            agent|web_search|agent_batch|make_todos|undo|request) ;;
                            *)
                                # ── bash: emit formatted command before tool_start ──
                                if [[ "$tool_name" == "bash" ]]; then
                                    local _pre_cmd; _pre_cmd=$(jq -r '.command // ""' <<< "$tool_input")
                                    local _pre_fmt; _pre_fmt=$(printf '%s' "$_pre_cmd" | _bash_format 2>/dev/null)
                                    if [[ -n "$_pre_fmt" ]]; then
                                        local _pf_max=30 _pf_i _pf_line _pf_count
                                        local -a _pf_lines
                                        mapfile -t _pf_lines <<< "$_pre_fmt"
                                        _pf_count=${#_pf_lines[@]}

                                        # ── Phase 1: scan max display width (ANSI-stripped) ──
                                        local _max_w=0 _bare _w _pf_limit
                                        _pf_limit=$((_pf_count < _pf_max ? _pf_count : _pf_max))
                                        for ((_pf_i=0; _pf_i<_pf_limit; _pf_i++)); do
                                            _pf_line="${_pf_lines[_pf_i]}"
                                            [[ -z "$_pf_line" ]] && _pf_line=" "
                                            _bare=$(_strip_ansi_sgr "$_pf_line")
                                            _w=${#_bare}
                                            (( _w > _max_w )) && _max_w=$_w
                                        done

                                        # ── Phase 2: box dimensions (clamp to terminal) ──
                                        local _box_w _inner _max_disp _term_w=${TERM_WIDTH:-80}
                                        _box_w=$((_max_w + 6))
                                        (( _box_w < 14 )) && _box_w=14
                                        (( _box_w > _term_w - 2 )) && _box_w=$((_term_w - 2))
                                        _inner=$((_box_w - 2))
                                        _max_disp=$((_inner - 2))

                                        # ── Phase 3: top border ╭── Script ──...──╮ ──
                                        local _lty _rst
                                        _lty="$LIGHT_YELLOW"
                                        _rst="$RESET"
                                        local _h; printf -v _h "%${_box_w}s" ""; _h="${_h// /─}"
                                        local _pad_w=$((_box_w - 14))
                                        (( _pad_w < 0 )) && _pad_w=0
                                        _stream_emit "text" "$(jq -nc --arg c "${_lty}╭── Script ──${_h:0:_pad_w}╮${_rst}" '{content: $c}')" || true

                                        # ── Phase 4: content lines │  text  │ ──
                                        local _disp _disp_w
                                        for ((_pf_i=0; _pf_i<_pf_limit; _pf_i++)); do
                                            _pf_line="${_pf_lines[_pf_i]}"
                                            [[ -z "$_pf_line" ]] && _pf_line=" "
                                            _bare=$(_strip_ansi_sgr "$_pf_line")
                                            _w=${#_bare}
                                            if (( _w > _max_disp )); then
                                                local _cut=$((_max_disp - 1))
                                                _disp="${_bare:0:_cut}…"
                                                _disp_w=$((_cut + 1))
                                            else
                                                _disp="$_pf_line"
                                                _disp_w=$_w
                                            fi
                                            _pad_w=$((_box_w - 4 - _disp_w))
                                            (( _pad_w < 0 )) && _pad_w=0
                                            local _pad_spaces; printf -v _pad_spaces "%${_pad_w}s" ""
                                            _stream_emit "text" "$(jq -nc --arg c "${_lty}│${_rst}  ${_disp}${_pad_spaces}${_lty}│${_rst}" '{content: $c}')" || true
                                        done

                                        # ── truncation notice (if > 30 lines) ──
                                        if (( _pf_count > _pf_max )); then
                                            local _pf_remaining=$((_pf_count - _pf_max))
                                            _stream_emit "text" "$(jq -nc --arg c "  ...(${_pf_remaining} more lines)" '{content: $c}')" || true
                                        fi

                                        # ── Phase 5: bottom border ╰──...──╯ ──
                                        _stream_emit "text" "$(jq -nc --arg c "${_lty}╰${_h:0:$((_box_w - 2))}╯${_rst}" '{content: $c}')" || true
                                    fi
                                fi
                                _stream_emit "tool_start" "$(jq -nc \
                                   --arg n "$tool_name" --arg id "$tool_id" --arg d "${_tsub_label:-}" \
                                   '{name: $n, id: $id, desc: $d}')" || { local _rc=$?; log "DEBUG: [PIPE] tool_start emit rc=$_rc (FIFO broken?)"; true; }
                                ;;
                        esac
                        _tool_out_file=$(_mktemp_file /tmp/bashagt_tool.XXXXXX 2>/dev/null)
                        if [[ -n "$_tool_out_file" && -f "$_tool_out_file" ]]; then
                            dispatch_tool "$tool_name" "$tool_input" > "$_tool_out_file" || exit_code=$?
                            tool_output=$(< "$_tool_out_file")
                            # Strip any leaked JSONL status frames (defense in depth)
                            tool_output=$(grep -v '^{"type":"\(status_\|tool_output\|tool_tick\)' <<< "$tool_output")
                            rm -f "$_tool_out_file"
                        else
                            tool_output=$(dispatch_tool "$tool_name" "$tool_input") || exit_code=$?
                        fi
                    fi
                    _ts_elapsed=$(( $(_timestamp_ms) - _ts_start ))
                    (( _ts_elapsed < 0 )) && _ts_elapsed=0
                    _log_tool_done "$tool_name" "${exit_code:-0}" "${#tool_output}"
                    local _terr="false"
                    [[ -n "${exit_code:-}" && "$exit_code" -ne 0 ]] && _terr="true"

                    # ── post_tool hook — tool result quality gate ──
                    local _pt_hook_ctx _pt_hook_results _pt_hook_item
                    _pt_hook_ctx=$(jq -nc \
                        --arg tool "$tool_name" \
                        --argjson input "$tool_input" \
                        --arg output "$tool_output" \
                        --argjson exit_code "${exit_code:-0}" \
                        --argjson elapsed_ms "$_ts_elapsed" \
                        '{tool:$tool,input:$input,output:$output,exit_code:$exit_code,elapsed_ms:$elapsed_ms}')
                    _pt_hook_results=$(_hook_fire "post_tool" "$_pt_hook_ctx")
                    if [[ "$_pt_hook_results" != "[]" && -n "$_pt_hook_results" ]]; then
                        while IFS= read -r _pt_hook_item; do
                            [[ -z "$_pt_hook_item" ]] && continue
                            local _aug; _aug=$(jq -r '.augment // false' <<< "$_pt_hook_item" 2>/dev/null)
                            if [[ "$_aug" == "true" ]]; then
                                local _suf; _suf=$(jq -r '.output_suffix // ""' <<< "$_pt_hook_item" 2>/dev/null)
                                [[ -n "$_suf" ]] && tool_output+="$_suf"
                            fi
                            local _repl; _repl=$(jq -r '.output_replace // ""' <<< "$_pt_hook_item" 2>/dev/null)
                            [[ -n "$_repl" ]] && tool_output="$_repl"
                        done < <(jq -c '.[]' <<< "$_pt_hook_results" 2>/dev/null)
                    fi
                    if (( ! _safe_skip )); then
                        case "$tool_name" in
                            agent|web_search|agent_batch|make_todos|undo|request) ;;
                            *) _stream_emit "tool_end" "$(jq -nc \
                                   --arg n "$tool_name" --arg e "$(ui_time $_ts_elapsed)" --argjson err "$_terr" \
                                   '{name: $n, elapsed_str: $e, error: $err}')" || { local _rc=$?; log "DEBUG: [PIPE] tool_end emit rc=$_rc (FIFO broken?)"; true; } ;;
                        esac
                    fi

                    _turn_flush_feedback

                    # Update non-productive streak for post_response hook
                    if (( ! _safe_skip )); then
                        case "$tool_name" in
                            edit_file|write_file|bash|task_create|task_update|make_todos|\
                            agent|agent_batch|skill|request|mcp__*)
                                NON_PRODUCTIVE_STREAK=0 ;;
                            *)
                                NON_PRODUCTIVE_STREAK=$((NON_PRODUCTIVE_STREAK + 1)) ;;
                        esac
                    fi

                    # Accumulate tools for post-hoc tree display
                    if (( ! _safe_skip )); then
                        case "$tool_name" in
                            web_search|agent|agent_batch|make_todos|undo|request) ;;
                            *)
                                local _tdesc=""
                                _tdesc=$(jq -r '.description // ""' <<< "$tool_input" 2>/dev/null)
                                [[ "$_tdesc" == "null" ]] && _tdesc=""
                                _tdesc="${_tdesc//$'\n'/ }"
                                case "$tool_name" in
                                    list_files|read_file|delete_file)
                                        local _tpath=""
                                        _tpath=$(jq -r '.path // ""' <<< "$tool_input" 2>/dev/null)
                                        [[ "$_tpath" == "null" ]] && _tpath=""
                                        [[ -n "$_tpath" ]] && _tdesc="${_tdesc} ${LIGHT_YELLOW}·${RESET} ${_tpath}"
                                        (( ${#_tdesc} > 144 )) && _tdesc="${_tdesc:0:141}..."
                                        ;;
                                    *)  (( ${#_tdesc} > 80 )) && _tdesc="${_tdesc:0:77}..." ;;
                                esac
                                _TOOL_TREE_ENTRIES+="${tool_name}"$'\t'"$(ui_time $_ts_elapsed)"$'\t'"${_tdesc//$'\n'/ }"$'\n'
                                _TOOL_TREE_COUNT=$((_TOOL_TREE_COUNT + 1))
                                if [[ "$tool_name" == "edit_file" || "$tool_name" == "write_file" ]]; then
                                    local _diff_disp="${tool_output}" _diff_max=15000
                                    if (( ${#_diff_disp} > _diff_max )); then
                                        _diff_disp="${_diff_disp:0:$_diff_max}"$'\n  ...[truncated]'
                                    fi
                                    _TOOL_DIFF_CONTENTS[_TOOL_TREE_COUNT]="$_diff_disp"
                                fi
                                ;;
                        esac
                    fi

                    local is_error="false"
                    if [[ -n "${exit_code:-}" ]] && [[ "$exit_code" -ne 0 ]]; then
                        is_error="true"
                    fi

                    local result
                    result=$(format_tool_result "$tool_id" "$tool_output" "$is_error")
                    results_json=$(echo "$results_json" | jq --argjson r "$result" '. + [$r]')
                    [[ "${_bagt_interrupted:-0}" == "1" ]] && break
                done

                if [[ "${_SAFE_DENIED:-0}" == "1" ]]; then
                    # Add deferred assistant message + denial tool_results to history
                    _turn_flush_assistant
                    msg_add_tool_results "$results_json"
                    accumulate_turn_tokens
                    local _ts_msg; _ts_msg=$(printf '%s %s │ %s %s' \
                        "$(ui_label '──' gray)" \
                        "$(ui_time $(( $(_timestamp_ms) - turn_start )))" \
                        "$(ui_tokens $TURN_INPUT_ACCUM $TURN_OUTPUT_ACCUM)" \
                        "$(ui_label '──' gray)")
                    _stream_emit "info" "$(jq -nc --arg c "$_ts_msg" '{content: $c}')"
                    _log_flush
                    save_history
                    return 0
                fi

                # Emit tool tree as JSONL frame
                if (( _TOOL_TREE_COUNT > 0 )); then
                    local _entries_json _tidx=0
                    _entries_json=$(printf '%s' "$_TOOL_TREE_ENTRIES" | \
                    while IFS=$'\t' read -r _tn _te _td; do
                        [[ -z "$_tn" ]] && continue
                        _tidx=$((_tidx + 1))
                        if [[ "$_tn" == "edit_file" || "$_tn" == "write_file" ]]; then
                            jq -nc --arg n "$_tn" --arg e "$_te" --arg d "${_td:-}" \
                                --arg c "${_TOOL_DIFF_CONTENTS[$_tidx]:-}" \
                                '{name: $n, elapsed_str: $e, desc: $d, contents: $c}'
                        else
                            jq -nc --arg n "$_tn" --arg e "$_te" --arg d "${_td:-}" \
                                '{name: $n, elapsed_str: $e, desc: $d}'
                        fi
                    done | jq -s '.')
                    _stream_emit "tree" "$(jq -nc --argjson entries "$_entries_json" '{entries: $entries}')"
                fi

                # Add assistant message BEFORE tool results, then flush plan messages.
                # _turn_flush_assistant handles _DEFERRED_CONTENT_JSON + _PLAN_DEFERRED_MSG + _PLAN_STOP.
                _turn_flush_assistant || {
                    # Plan rejected/cancelled → end turn, return to REPL
                    msg_add_tool_results "$results_json"
                    turn_end=$(_timestamp_ms)
                    if [[ "$turn_start" != "0" ]] && [[ "$turn_end" != "0" ]]; then
                        elapsed_ms=$(( turn_end - turn_start ))
                    else
                        elapsed_ms=0
                    fi
                    accumulate_turn_tokens
                    local _ts_msg; _ts_msg=$(printf '%s %s │ %s %s' \
                        "$(ui_label '──' gray)" \
                        "$(ui_time $elapsed_ms)" \
                        "$(ui_tokens $TURN_INPUT_ACCUM $TURN_OUTPUT_ACCUM)" \
                        "$(ui_label '──' gray)")
                    _stream_emit "info" "$(jq -nc --arg c "$_ts_msg" '{content: $c}')"
                    log "DEBUG: TURN_END  stop=plan_stop rounds=$_round tok_in=$TURN_INPUT_TOKENS tok_out=$TURN_OUTPUT_TOKENS"
                    _log_flush
                    save_history
                    return 0
                }
                msg_add_tool_results "$results_json"
                accumulate_turn_tokens
                TURN_PREV_STOP="tool_use"
                true  # placeholder — consecutive tool rounds removed
                # Reload agents after task calls (agent_manager may have created/deleted)
                [[ "$called_task" == "1" ]] && { load_agents; invalidate_tools_cache; _cc_invalidate system; called_task=0; }
                # Process scheduled job queue: reap completed, launch queued
                _agent_sched_tick

                # Daemon async request: force end_turn after emitting pending frame.
                # Oneshot stream request: poll for hotkey response, then continue.
                if (( _REQUEST_PENDING )); then
                    _REQUEST_PENDING=0
                    if (( ${BASHAGT_STREAM_MODE:-0} )) && [[ "${BASHAGT_DAEMON_WORKER:-0}" != "1" ]]; then
                        # Oneshot fallback: wait for hotkey to write user choice
                        local _choice _resp_file="${_RESPONSE_FILE:-}"
                        _RESPONSE_FILE=""
                        if [[ -n "$_resp_file" ]]; then
                            while [[ ! -f "$_resp_file" ]]; do sleep 0.1; done
                            _choice=$(cat "$_resp_file" 2>/dev/null || echo '{"result":"cancelled","choice_index":-1}')                            rm -f "$_resp_file"
                        else
                            _choice='{"result":"cancelled","choice_index":-1}'
                        fi
                        if (( _PLAN_PENDING )); then
                            _PLAN_PENDING=0
                            _plan_handle_response "$_choice" "${BASHAGT_PROJECT_DIR:-$PWD}"
                        else
                            msg_add_user_text "## Human response to oversight request
Selected: ${_choice:-cancelled}"
                        fi
                        continue
                    else
                        # Daemon path: end turn, daemon gateway will re-wake worker on POST /respond
                        _stream_emit "done" "$(jq -nc --arg e "$(ui_time $(( $(_timestamp_ms) - turn_start )))" '{elapsed_str: $e, tokens_in: 0, tokens_out: 0}')" 2>/dev/null || true
                        log "DEBUG: TURN_END  stop=daemon rounds=$_round tok_in=$TURN_INPUT_TOKENS tok_out=$TURN_OUTPUT_TOKENS"
                        _log_flush
                        save_history
                        return 0
                    fi
                fi

                :  # text-length reward removed
                ;;

            *)
                log "Unexpected stop_reason: ${STOP_REASON:-empty}, treating as end_turn"
                log "DEBUG: TURN_END  stop=unexpected rounds=$_round tok_in=$TURN_INPUT_TOKENS tok_out=$TURN_OUTPUT_TOKENS"
                accumulate_turn_tokens
                _turn_flush_assistant
                _log_flush
                save_history
                return
                ;;
        esac

        # End-of-iteration context check
        if ! check_turn_budget; then
            break
        fi
    done
    _log_flush
}

# ============================================================================
# SECTION 11b: Adaptive Agent Loop — turn budget, per-call adaptation, context
# ============================================================================

# Estimate tokens in current context window.
# Uses MESSAGES byte size, not cumulative session counters — SESSION_INPUT_TOKENS
# grows unboundedly across turns (each API call sends system+tools+messages,
# so cumulative total is N× per-turn, not current window usage).
# Rough: ~3.5 chars/token + ~28K fixed overhead (system prompt + tools + env context)
estimate_context_tokens() {
    local bytes=${#MESSAGES}
    ESTIMATED_CONTEXT_TOKENS=$(( bytes * 10 / 35 + 28000 ))
}

# Check context window pressure; auto-compress or force stop
# Returns: 0 = ok, 1 = force end_turn
context_window_check() {
    estimate_context_tokens
    local safe_tokens=$(( BASHAGT_CONTEXT_WINDOW * BASHAGT_CONTEXT_SAFE_RATIO / 100 ))
    local warn_tokens=$(( BASHAGT_CONTEXT_WINDOW * (BASHAGT_CONTEXT_SAFE_RATIO + 10) / 100 ))
    local critical_tokens=$(( BASHAGT_CONTEXT_WINDOW * 95 / 100 ))

    if (( ESTIMATED_CONTEXT_TOKENS > critical_tokens )); then
        log "CRITICAL: context near limit (${ESTIMATED_CONTEXT_TOKENS}/${BASHAGT_CONTEXT_WINDOW}tok)"
        return 1
    elif (( ESTIMATED_CONTEXT_TOKENS > warn_tokens )); then
        # Anti-recompress cooldown: skip if compressed recently (within 5 turns)
        local _turns_since=$(( TURN_COUNTER - LAST_COMPRESS_TURN ))
        if (( _turns_since < 5 )); then
            log "Skipping auto-compress: last was ${_turns_since} turns ago (cooldown=5)"
            return 0
        fi
        log "Auto-compressing context (${ESTIMATED_CONTEXT_TOKENS}/${BASHAGT_CONTEXT_WINDOW}tok)"
        compress_context --async
        estimate_context_tokens
        return 0
    elif (( ESTIMATED_CONTEXT_TOKENS > safe_tokens )); then
        log "Context approaching limit (${ESTIMATED_CONTEXT_TOKENS}/${BASHAGT_CONTEXT_WINDOW}tok)"
    fi
    return 0
}

# Compute per-call token budget based on turn phase and remaining capacity.
# Three tiers — 100% / 75% / 50% of base max_tokens and thinking_budget.
# Tier progression within a turn:
#   iter=1           → 100% (full reasoning, fresh start)
#   auto-continue    → 100% (model hasn't started real work yet)
#   after tool_use   →  75% (moderate — still need to process results + plan next)
#   3+ consec. tools →  50% (reduced — tight tool-execution loop)
#   max_tokens stop  →  50% (aggressive — prevent runaway)
# Budget pressure (>70% turn budget used) drops tier by one level.
compute_call_budget() {
    local iter="$1" prev_stop="$2"
    local base_max="${BASHAGT_MAX_TOKENS:-24576}"

    # Default: full budget
    EFFECTIVE_MAX_TOKENS="$base_max"
    EFFECTIVE_THINKING_BUDGET="$base_max"

    # --- Determine tier (100 / 75 / 50) ---
    local tier=100

    if (( iter == 1 )) || [[ "$prev_stop" == "" ]] || [[ "$prev_stop" == "end_turn" ]]; then
        # Full budget: first call, auto-continue, or text-only response
        tier=100
    elif [[ "$prev_stop" == "tool_use" ]]; then
        # After tool execution: start at 75%
        tier=75
    fi

    # Apply tier percentage
    EFFECTIVE_MAX_TOKENS=$(( base_max * tier / 100 ))
    EFFECTIVE_THINKING_BUDGET=$EFFECTIVE_MAX_TOKENS

    # Absolute floor — never below usable minimum
    (( EFFECTIVE_MAX_TOKENS < 512 )) && EFFECTIVE_MAX_TOKENS=512
    return 0
}

# Accumulate per-call tokens into turn + session counters
accumulate_turn_tokens() {
    TURN_TOKENS_USED=$(( TURN_TOKENS_USED + TURN_INPUT_TOKENS + TURN_OUTPUT_TOKENS ))
    TURN_INPUT_ACCUM=$(( TURN_INPUT_ACCUM + TURN_INPUT_TOKENS ))
    TURN_OUTPUT_ACCUM=$(( TURN_OUTPUT_ACCUM + TURN_OUTPUT_TOKENS ))
    SESSION_INPUT_TOKENS=$(( SESSION_INPUT_TOKENS + TURN_INPUT_TOKENS ))
    SESSION_OUTPUT_TOKENS=$(( SESSION_OUTPUT_TOKENS + TURN_OUTPUT_TOKENS ))
}

# Check turn budget thresholds; returns "ok" or "exhausted"

# Post-iteration context check; returns 0=continue, 1=stop
check_turn_budget() {
    if ! context_window_check; then
        _stream_emit "warning" "$(jq -nc --arg c 'Context window exceeded — turn stopped to prevent data loss.' '{content: $c}')"
        return 1
    fi
    return 0
}

# Print session summary on exit
print_session_summary() {
    (( BASHAGT_STREAM_MODE )) && return 0
    printf '\n  %s %s %s\n' \
        "$(ui_label '── session:' gray)" \
        "$(ui_tokens $SESSION_INPUT_TOKENS $SESSION_OUTPUT_TOKENS)" \
        "$(ui_label '──' gray)"
}

print_banner() {
    printf '\n'
    printf '  %s██████╗  █████╗ ███████╗██╗  ██╗ █████╗  ██████╗ ████████╗%s\n' "$BANNER_LOGO_L1" "$RESET"
    printf '  %s██╔══██╗██╔══██╗██╔════╝██║  ██║██╔══██╗██╔════╝ ╚══██╔══╝%s\n' "$BANNER_LOGO_L2" "$RESET"
    printf '  %s██████╔╝███████║███████╗███████║███████║██║  ███╗   ██║%s\n' "$BANNER_LOGO_L3" "$RESET"
    printf '  %s██╔══██╗██╔══██║╚════██║██╔══██║██╔══██║██║   ██║   ██║%s\n' "$BANNER_LOGO_L4" "$RESET"
    printf '  %s██████╔╝██║  ██║███████║██║  ██║██║  ██║╚██████╔╝   ██║%s\n' "$BANNER_LOGO_L5" "$RESET"
    printf '  %s╚═════╝ ╚═╝  ╚═╝╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝ ╚═════╝    ╚═╝%s\n' "$BANNER_LOGO_L6" "$RESET"
    printf '\n'

    local right_col=40 pad_len pad
    # pad_len = right_col - indent(2) - label - sep(1) - value - sep(1) - pos_adj(1)
    #         = right_col - 5 - label_len - value_len

    # Line 1: Profile (if active)
    local _disp_prof; _disp_prof=$(_prof_get_field name)
    if [[ "$_disp_prof" != "default" ]]; then
        printf '  %sProfile:%s %s → %s\n' "$BANNER_LABEL" "$RESET" "$_disp_prof" "$(_prof_get_field model)"
    fi

    # Line 2: Model + Thinking
    pad_len=$(( right_col - 5 - 6 - ${#BASHAGT_MODEL} ))  # label="Model:"=6
    (( pad_len < 1 )) && pad_len=1
    printf -v pad '%*s' "$pad_len" ''
    printf '  %sModel:%s %s%s %sThinking:%s %s\n' \
        "$BANNER_LABEL" "$RESET" "$BASHAGT_MODEL" "$pad" "$BANNER_LABEL" "$RESET" "$BASHAGT_SHOW_THINKING"

    # Line 3: Endpoint
    printf '  %sEndpoint:%s %s\n' \
        "$BANNER_LABEL" "$RESET" "$(echo "$BASHAGT_API_URL" | sed 's|https://||;s|/v1/.*||')"

    # Line 3: Author + Version
    pad_len=$(( right_col - 5 - 7 - 5 ))  # label="Author:"=7, value="Lucas"=5
    (( pad_len < 1 )) && pad_len=1
    printf -v pad '%*s' "$pad_len" ''
    printf '  %sAuthor:%s %s%s %sVersion:%s preview-0.1\n' \
        "$BANNER_LABEL" "$RESET" "Lucas" "$pad" "$BANNER_LABEL" "$RESET"

    printf '\n'
}

# ── Persistent renderer: one-time FIFO creation + renderer fork ──
# Called once in main() after print_banner. Renderer lives until process exit.
_persistent_renderer_init() {
    _PERSISTENT_FIFO=$(_mktemp_u /tmp/bashagt_ps.XXXXXX)
    mkfifo "$_PERSISTENT_FIFO" 2>/dev/null || { log "ERROR: persistent FIFO creation failed"; return 1; }
    # Response FIFO: renderer writes "ok" after processing "done" so main can sync.
    _PERSISTENT_RESP_FIFO=$(_mktemp_u /tmp/bashagt_rs.XXXXXX)
    mkfifo "$_PERSISTENT_RESP_FIFO" 2>/dev/null || { log "ERROR: response FIFO creation failed"; return 1; }
    # Open O_RDWR so both sides can open without blocking (FIFO rendezvous bypass).
    exec 6<>"$_PERSISTENT_RESP_FIFO"
    # Fork renderer first. The child's open-read blocks until a writer opens.
    # We then open the write end (O_WRONLY, also blocks until reader exists).
    # POSIX FIFO rendezvous: both open() calls resolve each other.
    # After exec 7> returns, the renderer IS ready to receive data.
    (
        # Kill timer child on ANY exit path (EOF, SIGTERM, SIGHUP).
        # Without this the timer is orphaned → writes to /dev/tty forever.
        _renderer_cleanup() {
            [[ -n "${_R_SPIN_TIMER_PID:-}" ]] && kill "$_R_SPIN_TIMER_PID" 2>/dev/null || true
            printf "\r\033[K" >/dev/tty 2>/dev/null || true
        }
        trap '_renderer_cleanup' EXIT
        trap '_renderer_cleanup; exit 0' TERM HUP
        while IFS= read -r _line; do
            _stream_render "$_line" || true  # done frame does NOT exit renderer
        done < "$_PERSISTENT_FIFO"
    ) 1>&2 &
    _PERSISTENT_RENDERER_PID=$!
    _proc_register "$_PERSISTENT_RENDERER_PID" "renderer" "persistent"
    # Blocks until renderer opens read end (FIFO rendezvous).
    # After open() succeeds, renderer still needs ~1 scheduler tick to enter read loop.
    exec 7>"$_PERSISTENT_FIFO"
    sleep 0.05  # let renderer settle into read() before any caller opens another write end
    log "DEBUG: [FIFO] persistent renderer pid=$_PERSISTENT_RENDERER_PID fifo=$_PERSISTENT_FIFO"
}

# Called in EXIT trap. Closes persistent write end → renderer gets EOF → exits.
_persistent_renderer_teardown() {
    log "DEBUG: [FIFO] teardown start _PERSISTENT_RENDERER_PID=[${_PERSISTENT_RENDERER_PID:-}]"
    [[ -n "${_PERSISTENT_RENDERER_PID:-}" ]] || return 0
    exec 7>&- 2>/dev/null || true       # close persistent write end
    # Other fds (1, 8) may still point to the FIFO (e.g. inside _stream_wrap_turn
    # during /exit). Send SIGTERM to guarantee renderer exit, SIGKILL as fallback.
    _proc_kill "$_PERSISTENT_RENDERER_PID" TERM
    local _wait_n=0
    while kill -0 "$_PERSISTENT_RENDERER_PID" 2>/dev/null && (( _wait_n < 20 )); do
        sleep 0.05; _wait_n=$((_wait_n + 1))
    done
    kill -0 "$_PERSISTENT_RENDERER_PID" 2>/dev/null && _proc_kill "$_PERSISTENT_RENDERER_PID" KILL
    wait "$_PERSISTENT_RENDERER_PID" 2>/dev/null || true
    rm -f "$_PERSISTENT_FIFO" 2>/dev/null || true
    exec 6>&- 2>/dev/null || true              # close response FIFO fd
    rm -f "$_PERSISTENT_RESP_FIFO" 2>/dev/null || true
    _PERSISTENT_RENDERER_PID=""
    _PERSISTENT_FIFO=""
    _PERSISTENT_RESP_FIFO=""
    log "DEBUG: [FIFO] persistent renderer torn down"
}

# ── Stream-mode turn wrapper: redirect stdout→FIFO, run turn, render JSONL→ANSI ──
# Unified from _stream_run_turn + _stream_run_oneshot.
#   _to_stderr=true  → interactive REPL (renderer → stderr, INT trap, trailing \n)
#   _to_stderr=false → oneshot/pipe (renderer → original stdout, no INT trap, no \n)
_stream_wrap_turn() {
    local _prompt="$1" _to_stderr="${2:-true}" _fifo _renderer_pid _persistent=0
    _colors_resolve  # sync colors to current dark/light mode
    _tm "wrap_turn_start"
    log "DEBUG: STREAM_WRAP input_size=${#_prompt}"

    log "DEBUG: STREAM_WRAP persistent=${_PERSISTENT_RENDERER_PID:-NONE} to_stderr=$_to_stderr fd1=$(readlink /proc/self/fd/1 2>/dev/null || echo ?) fd7=$(readlink /proc/self/fd/7 2>/dev/null || echo ?) fd8=$(readlink /proc/self/fd/8 2>/dev/null || echo ?)"
    if [[ $_to_stderr == "true" && -n "${_PERSISTENT_RENDERER_PID:-}" ]]; then
        # ── Persistent path: reuse FIFO + renderer (interactive mode) ──
        if ! kill -0 "$_PERSISTENT_RENDERER_PID" 2>/dev/null; then
            log "DEBUG: [FIFO] persistent renderer died, restarting"
            _persistent_renderer_init || {
                printf '[bashagt] persistent renderer restart failed\n' >&2
                return 1
            }
        fi
        _persistent=1
        _fifo="$_PERSISTENT_FIFO"
        # Redirect stdout to persistent FIFO (renderer already running, fd 7 keeps alive)
        exec 9>&1
        exec 1>"$_fifo"
        exec 8>&1
        _BYPASS_FD=8
    else
        # ── Per-turn path: new FIFO + new renderer (oneshot pipe, first call fallback) ──
        _fifo=$(_mktemp_u /tmp/bashagt_stm.XXXXXX)
        mkfifo "$_fifo" 2>/dev/null || { printf '[bashagt] FIFO error\n' >&2; return 1; }
        if [[ $_to_stderr == "true" ]]; then
            ( while IFS= read -r _line; do _stream_render "$_line" || break; done < "$_fifo" ) 1>&2 &
        else
            ( while IFS= read -r _line; do _stream_render "$_line" || break; done < "$_fifo" ) &
        fi
        _renderer_pid=$!
        exec 9>&1
        exec 1>"$_fifo"
        exec 8>&1
        _BYPASS_FD=8
    fi
    # Emit spinner frame — renderer self-animates on status_begin.
    # Skip for slash commands (with or without /): they process locally.
    local _first_word="${_prompt%% *}"
    _first_word="${_first_word#/}"
    if [[ -z "${SLASH_COMMANDS[$_first_word]:-}" ]]; then
        _stream_kv status_begin icon "$(ui_spinner)" label "Thinking..." elapsed_str "0s" >&8
    fi
    log "DEBUG: [FIFO] setup persistent=$_persistent fd8=$( [[ -e /proc/self/fd/8 ]] && echo ok || echo MISSING ) fd9=$( [[ -e /proc/self/fd/9 ]] && echo ok || echo MISSING ) fifo=$_fifo"

    # Interactive mode: replace INT trap with soft-interrupt for the API call
    local _saved_int
    if [[ $_to_stderr == "true" ]]; then
        _saved_int=$(trap -p INT 2>/dev/null)
        trap '_bagt_interrupted=1; log "DEBUG: [INT] SIGINT trap — set _bagt_interrupted=1"' INT
        _bagt_interrupted=0
        log "DEBUG: [INT] turn start — cleared _bagt_interrupted"
        _SAFE_DENIED=0
    fi

    # Run turn — all _stream_emit output goes to FIFO → renderer
    _tm "wrap_before_run_turn"
    run_turn "$_prompt" || true
    (( STATUS_ACTIVE )) && { printf '\n\033[K' >&2; STATUS_ACTIVE=0; }

    # Restore INT trap (interactive only)
    if [[ $_to_stderr == "true" ]]; then
        if [[ -n "$_saved_int" ]]; then eval "$_saved_int"
        else trap - INT; fi
    fi

    # Emit "done" LAST — renderer writes "ok" to fd 6 after processing all frames.
    _stream_emit "done" "$(jq -nc '{elapsed_str: "", tokens_in: 0, tokens_out: 0}')" 2>/dev/null || true
    if (( _persistent )); then
        # Block until renderer drains FIFO (processes done → writes "ok" to fd 6).
        # Prevents async renderer stderr output racing with REPL › prompt on stdout.
        IFS= read -r -t 2 _renderer_ack <&6 2>/dev/null || true
    fi

    # Restore stdout — close FIFO write ends (but fd 7 keeps persistent renderer alive)
    local _was_errexit=0; [[ -o errexit ]] && _was_errexit=1
    set +e
    _BYPASS_FD=1
    exec 1>&9 9>&- 8>&-
    local _exec_rc=$?
    log "DEBUG: [FIFO] teardown persistent=$_persistent rc=$_exec_rc fd1=$( [[ -e /proc/self/fd/1 ]] && echo ok || echo MISSING ) fd8=$( [[ -e /proc/self/fd/8 ]] && echo closed || echo STILL_OPEN )"
    (( _was_errexit )) && set -e
    if (( _exec_rc != 0 )); then
        log "DEBUG: [FIFO] fd recovery: exec 1>&9 failed (rc=$_exec_rc), trying 1>&2"
        exec 1>&2 2>/dev/null || { log "DEBUG: [FIFO] fd recovery: 1>&2 also failed, trying /dev/tty"; exec 1>/dev/tty 2>/dev/null || { log "DEBUG: [FIFO] fd recovery: ALL fallbacks exhausted"; true; }; }
    fi

    if (( _persistent )); then
        # fd 7 keeps FIFO alive — renderer blocks, waits for next turn
        :
    else
        # Per-turn: no fd 7 → renderer got EOF → wait + cleanup
        wait $_renderer_pid 2>/dev/null || true
        rm -f "$_fifo"
    fi

    # Trailing newline for visual separation before next prompt (interactive only)
    [[ $_to_stderr == "true" ]] && printf '\n'
    log "DEBUG: STREAM_WRAP done persistent=$_persistent fd1=$(readlink /proc/self/fd/1 2>/dev/null || echo ?)"
}

agent_loop() {
    # ── HTTP handler mode: single connection (spawned by socat fork) ──
    if [[ "${BASHAGT_MODE:-interactive}" == "http_handler" ]]; then
        _http_handler
        return
    fi

    # ── Non-interactive: oneshot or pipe — unified stdin path ──
    if [[ "${BASHAGT_MODE:-interactive}" == "oneshot" ]] || [[ ! -t 0 ]]; then
        trap 'mcp_shutdown; save_history; exit 130' INT
        trap '(( STATUS_ACTIVE )) && printf "\n\033[K" >&2 || true; _persistent_renderer_teardown; _hook_fire "on_cleanup" "{}" >/dev/null 2>&1 || true; _proc_shutdown; save_history' EXIT TERM
        _pe_cache_init
        # Read up to 1MB — cap prevents OOM on accidental binary pipe
        local input; input=$(dd bs=1048576 count=1 2>/dev/null)
        if [[ -n "$input" ]]; then
            _tm "oneshot_input_read"
            if [[ "${BASHAGT_OE_RAW:-0}" == "1" ]]; then
                # Raw JSONL passthrough (daemon worker, hotkey, --stream)
                # Open bypass fd so async_spin's _stream_emit survives
                # dispatch_tool > "$file" redirects (same pattern as
                # _stream_run_turn / _stream_run_oneshot L6740).
                exec 8>&1
                _BYPASS_FD=8
                _tm "oneshot_before_run_turn"
                run_turn "$input" || true
                # Emit "done" after all turn output (moved from call_api_nonstreaming)
                _stream_emit "done" "$(jq -nc '{elapsed_str: "", tokens_in: 0, tokens_out: 0}')" 2>/dev/null || true
            else
                # Render JSONL to ANSI for human consumption
                _stream_wrap_turn "$input" false
            fi
            save_history
        fi
        return
    fi

    # Interactive mode
    _proc_init || true
    print_banner
    _persistent_renderer_init || true  # one-time FIFO + renderer (survives all turns)
    log "DEBUG: [FIFO] init done persistent_pid=${_PERSISTENT_RENDERER_PID:-NONE} fifo=${_PERSISTENT_FIFO:-NONE}"

    # Command history file (loaded/saved by input layer)
    local cmd_history="$HOME/.bashagt/cmd_history"

    # Slash-command completer — auto-generated from SLASH_COMMANDS registry
    _bashagt_complete() {
        local line="$1" cmd
        [[ "$line" == /* ]] || return 0
        for cmd in "${!SLASH_COMMANDS[@]}"; do
            [[ "/$cmd" == "$line"* ]] && printf '/%s\n' "$cmd"
        done
    }

    local user_input rl_prompt
    rl_prompt="  ${PROMPT_INPUT}›${RESET} "
    _input_init --prompt "$rl_prompt" --history "$cmd_history" --completer _bashagt_complete

    # Input-mode Ctrl-C handler — equivalent to /exit
    _input_int_handler() {
        _input_cleanup
        printf '\r\n\n'
        print_session_summary
        save_history
        # read -s interrupted by SIGINT restores its saved -echo state
        # AFTER the trap handler returns.  Prepend stty echo to the EXIT
        # trap that _input_cleanup just set.
        trap '(( STATUS_ACTIVE )) && printf "\n\033[K" >&2 || true; _persistent_renderer_teardown; _hook_fire "on_cleanup" "{}" >/dev/null 2>&1 || true; stty echo 2>/dev/null || true; _proc_shutdown; save_history' EXIT
        exit 0
    }

    _log_flush  # persist buffered startup logs before input loop
    log "DEBUG: TURN_LOOP  mode=interactive"

    trap '_safe_toggle_signal' USR1

    while true; do
        trap '_input_int_handler' INT
        _input_readline || {
            printf '\n'
            _input_cleanup
            print_session_summary
            save_history
            break
        }
        user_input="$REPLY"
        [[ -z "$user_input" ]] && continue
        _tm "interactive_input_received"
        _in_hist_add "$user_input"
        _in_hist_save "$cmd_history" "$user_input"
        _tm "interactive_hist_saved"
        _stream_wrap_turn "$user_input" true || true
    done
    _persistent_renderer_teardown  # clean exit (Ctrl-D, /exit)
}

# ============================================================================
# SECTION 11c: MCP Module — Model Context Protocol Client
# ============================================================================
# Implements MCP client supporting stdio, SSE, and Streamable HTTP transports.
# Integrates with bashagt tool system via mcp__<server>__<tool> name prefix.
#
# Config: settings.json `mcp_servers` key (4-tier: project > system > env > default)

# ── MCP State ──
declare -A MCP_SERVERS MCP_SERVER_PID MCP_SERVER_DIR MCP_SERVER_TOOLS
declare -A MCP_SERVER_CAPS MCP_SERVER_READY MCP_SERVER_URL MCP_SERVER_TRANSPORT
MCP_NEXT_REQUEST_ID=1
MCP_CONNECTED_COUNT=0

# ── mcp_load_config ──
# Reads MCP server definitions from settings.json `mcp_servers` key via 4-tier config.
# Project .bashagt/settings.json overrides ~/.bashagt/settings.json.
# Env: BASHAGT_MCP_SERVERS='{"name":{...}}'
#
# Example settings.json entry:
#   "mcp_servers": {
#     "filesystem": {"command":"npx", "args":["-y","@modelcontextprotocol/server-filesystem","/path"], "transport":"stdio"},
#     "remote":     {"url":"https://host:port/mcp/sse", "transport":"sse"}
#   }
mcp_load_config() {
    MCP_SERVERS=()
    local _json; _json=$(_get_setting "mcp_servers" "BASHAGT_MCP_SERVERS" "{}" 2>/dev/null || echo "{}")
    [[ "$_json" == "{}" || "$_json" == "null" ]] && return 0
    local _names; _names=$(jq -r 'keys[]' <<< "$_json" 2>/dev/null)
    local _name _cfg
    while IFS= read -r _name; do
        [[ -z "$_name" ]] && continue
        _cfg=$(jq -c --arg n "$_name" '.[$n]' <<< "$_json")
        MCP_SERVERS[$_name]="$_cfg"
    done <<< "$_names"
}

# ── JSON-RPC 2.0 Primitives ──

mcp_build_request() {  # method params_json → JSON-RPC request string
    local _method="$1" _params="${2:-{}}"
    local _id=$((MCP_NEXT_REQUEST_ID)); MCP_NEXT_REQUEST_ID=$((MCP_NEXT_REQUEST_ID + 1))
    jq -n --arg method "$_method" --argjson params "$_params" --argjson id "$_id" \
        '{jsonrpc:"2.0", method:$method, params:$params, id:$id}'
}

mcp_build_notification() {  # method params_json → JSON-RPC notification string
    local _method="$1" _params="${2:-{}}"
    jq -n --arg method "$_method" --argjson params "$_params" \
        '{jsonrpc:"2.0", method:$method, params:$params}'
}


# ── stdio Transport ──

mcp_transport_stdio_connect() {
    local _name="$1" _cfg="${MCP_SERVERS[$_name]}"
    local _cmd _args_json _dir _pid

    _cmd=$(jq -r '.command // ""' <<< "$_cfg")
    [[ -z "$_cmd" ]] && { log "MCP [$1]: no command"; return 1; }
    _args_json=$(jq -c '.args // []' <<< "$_cfg")

    _dir=$(_mktemp_dir "/tmp/bashagt_mcp_${_name}.XXXXXX")
    [[ -z "$_dir" || ! -d "$_dir" ]] && { log "MCP [$1]: mktemp failed"; return 1; }
    mkfifo "$_dir/in" "$_dir/out" 2>/dev/null || { log "MCP [$1]: fifo failed"; rm -rf "$_dir"; return 1; }

    # Build arg array (preserving spaces in individual args)
    local _args=()
    while IFS= read -r _a; do [[ -n "$_a" ]] && _args+=("$_a"); done < <(jq -r '.[]' <<< "$_args_json" 2>/dev/null)

    # Start server: export env vars from config, then pipe FIFO-in through command
    # cat keeps FIFO-in open across multiple writes
    (
        local _env_keys _env_key _env_val
        _env_keys=$(jq -r '.env // {} | keys[]' <<< "$_cfg" 2>/dev/null)
        while IFS= read -r _env_key; do
            [[ -z "$_env_key" ]] && continue
            _env_val=$(jq -r --arg k "$_env_key" '.env[$k]' <<< "$_cfg")
            export "${_env_key}=${_env_val}"
        done <<< "$_env_keys"
        cat "$_dir/in" 2>/dev/null | exec "$_cmd" "${_args[@]}" > "$_dir/out" 2>"$_dir/stderr.log"
    ) &
    _pid=$!
    sleep 0.2
    if ! kill -0 $_pid 2>/dev/null; then
        local _err; _err=$(head -3 "$_dir/stderr.log" 2>/dev/null)
        log "MCP [$_name]: server died on start: ${_err:-unknown}"
        rm -rf "$_dir"; return 1
    fi

    MCP_SERVER_PID[$_name]=$_pid
    _proc_register "$_pid" "mcp" "server:$_name"
    MCP_SERVER_DIR[$_name]="$_dir"
    MCP_SERVER_TRANSPORT[$_name]="stdio"

    # Record PID for crash recovery (daemon SIGKILL → orphan detection)
    local _track_pid="${BASHAGT_DAEMON_PID:-$$}"
    printf '' > "${TMPDIR:-/tmp}/bashagt_mcp_${_track_pid}_${_pid}"

    return 0
}

mcp_transport_stdio_send() {  # name json
    local _name="$1" _json="$2" _in="${MCP_SERVER_DIR[$_name]}/in"
    # Write to FIFO with timeout protection
    if ! printf '%s\n' "$_json" | ${TIMEOUT_CMD:-timeout} 5 cat > "$_in" 2>/dev/null; then
        if [[ -n "${TIMEOUT_CMD:-}" ]]; then
            log "MCP [$_name]: write timeout (server dead?)"
            return 1
        fi
        # No timeout cmd available — try direct write
        printf '%s\n' "$_json" > "$_in" 2>/dev/null || { log "MCP [$_name]: write failed"; return 1; }
    fi
    return 0
}

# Receives one JSON-RPC response from stdio FIFO.
# Reads lines with timeout, accumulates, validates with jq.
mcp_transport_stdio_recv() {
    local _name="$1" _timeout="${2:-30}" _out="${MCP_SERVER_DIR[$_name]}/out"
    local _json="" _line _start _now _fd

    _start=${EPOCHSECONDS:-$(date +%s)}
    exec {_fd}< "$_out" 2>/dev/null || { log "MCP [$_name]: cannot open FIFO for read"; return 1; }

    while true; do
        IFS= read -r -u $_fd -t 0.5 _line || {
            _now=${EPOCHSECONDS:-$(date +%s)}
            if (( _now - _start >= _timeout )); then
                exec {_fd}<&-; log "MCP [$_name]: recv timeout"; return 1
            fi
            continue
        }
        _json+="$_line"
        # Validate as complete JSON (compact single-line MCP responses)
        if echo "$_json" | jq '.' >/dev/null 2>&1; then
            exec {_fd}<&-
            printf '%s' "$_json"
            return 0
        fi
    done
}

# ── Internal: collect custom headers from MCP config JSON ──
# Outputs --header lines for consumption by readarray.
_mcp_collect_headers() {
    local _cfg="$1" _keys _k _v
    _keys=$(jq -r '.headers // {} | keys[]' <<< "$_cfg" 2>/dev/null)
    while IFS= read -r _k; do
        [[ -z "$_k" ]] && continue
        _v=$(jq -r --arg k "$_k" '.headers[$k]' <<< "$_cfg")
        printf '%s\n' "--header"
        printf '%s\n' "${_k}: ${_v}"
    done <<< "$_keys"
}

# ── SSE Transport ──

mcp_transport_sse_connect() {
    local _name="$1" _cfg="${MCP_SERVERS[$_name]}"
    local _url; _url=$(jq -r '.url // ""' <<< "$_cfg")
    [[ -z "$_url" ]] && { log "MCP [$_name]: no url for SSE"; return 1; }

    # SSE callback: capture endpoint event for message POST URL
    _mcp_sse_event_callback() {
        local _evt="$1" _data="$2"
        case "$_evt" in
            endpoint)
                local _msg_url="$_data"
                [[ "$_msg_url" != http* ]] && { local _base="${_url%/*}"; _msg_url="${_base}/${_msg_url#/}"; }
                MCP_SERVER_URL[$_name]="$_msg_url"
                ;;
            message)
                # Server→client JSON-RPC messages via SSE (queue for next recv)
                printf '%s\n' "$_data" >> "${MCP_SERVER_DIR[$_name]}/inbox.jsonl"
                ;;
        esac
    }

    local _dir; _dir=$(_mktemp_dir "/tmp/bashagt_mcp_${_name}.XXXXXX")
    MCP_SERVER_DIR[$_name]="$_dir"
    MCP_SERVER_TRANSPORT[$_name]="sse"

    local _hdr_args=()
    readarray -t _hdr_args < <(_mcp_collect_headers "$_cfg")
    http_sse_connect "$_url" "_mcp_sse_event_callback" \
        --connect-timeout "${BASHAGT_MCP_CONNECT_TIMEOUT:-10}" \
        "${_hdr_args[@]}"
    local _rc=$?
    [[ $_rc -ne 0 ]] && { log "MCP [$_name]: SSE connect failed"; rm -rf "$_dir"; return 1; }
    [[ -z "${MCP_SERVER_URL[$_name]:-}" ]] && { log "MCP [$_name]: no endpoint event received"; rm -rf "$_dir"; return 1; }

    return 0
}

mcp_transport_sse_send() {
    local _name="$1" _json="$2" _url="${MCP_SERVER_URL[$_name]}" _cfg="${MCP_SERVERS[$_name]}"
    local _out; _out=$(_mktemp_file /tmp/bashagt_mcpsse.XXXXXX)
    local _hdr_args=()
    readarray -t _hdr_args < <(_mcp_collect_headers "$_cfg")
    http_post "$_url" "$_out" \
        --connect-timeout "${BASHAGT_MCP_CONNECT_TIMEOUT:-10}" \
        --max-time "${BASHAGT_MCP_REQUEST_TIMEOUT:-60}" \
        --header "content-type: application/json" \
        "${_hdr_args[@]}" \
        --body "$_json"
    local _rc=$? _resp; _resp=$(< "$_out"); rm -f "$_out"
    if [[ $_rc -ne 0 ]]; then
        log "MCP [$_name]: SSE POST failed"; return 1
    fi
    printf '%s' "$_resp"
    return 0
}

mcp_transport_sse_recv() {
    local _name="$1" _timeout="${2:-30}" _inbox="${MCP_SERVER_DIR[$_name]}/inbox.jsonl"
    local _start _now _tmp _line
    _start=${EPOCHSECONDS:-$(date +%s)}
    while true; do
        if [[ -f "$_inbox" && -s "$_inbox" ]]; then
            _tmp=$(_mktemp_file /tmp/bashagt_mcpmsg.XXXXXX)
            if mv "$_inbox" "$_tmp" 2>/dev/null; then
                _line=$(head -1 "$_tmp")
                rm -f "$_tmp"
                [[ -n "$_line" ]] && { printf '%s' "$_line"; return 0; }
            fi
        fi
        _now=${EPOCHSECONDS:-$(date +%s)}
        (( _now - _start >= _timeout )) && { log "MCP [$_name]: SSE recv timeout"; return 1; }
        sleep 0.1
    done
}


# ── Streamable HTTP Transport ──

mcp_transport_http_connect() {
    local _name="$1" _cfg="${MCP_SERVERS[$_name]}"
    local _url; _url=$(jq -r '.url // ""' <<< "$_cfg")
    [[ -z "$_url" ]] && { log "MCP [$_name]: no url for HTTP"; return 1; }
    MCP_SERVER_URL[$_name]="$_url"
    MCP_SERVER_TRANSPORT[$_name]="http"
    MCP_SERVER_DIR[$_name]=$(_mktemp_dir "/tmp/bashagt_mcp_${_name}.XXXXXX")
    return 0
}

mcp_transport_http_send() {
    local _name="$1" _json="$2" _url="${MCP_SERVER_URL[$_name]}" _cfg="${MCP_SERVERS[$_name]}"
    local _out; _out="${MCP_SERVER_DIR[$_name]}/last_response"
    local _hdr_args=()
    readarray -t _hdr_args < <(_mcp_collect_headers "$_cfg")
    http_post "$_url" "$_out" \
        --connect-timeout "${BASHAGT_MCP_CONNECT_TIMEOUT:-10}" \
        --max-time "${BASHAGT_MCP_REQUEST_TIMEOUT:-60}" \
        --header "content-type: application/json" \
        "${_hdr_args[@]}" \
        --body "$_json"
    local _rc=$?
    if [[ $_rc -ne 0 ]]; then
        log "MCP [$_name]: HTTP POST failed"; return 1
    fi
    return 0
}

mcp_transport_http_recv() {
    local _name="$1" _timeout="${2:-60}" _resp_file="${MCP_SERVER_DIR[$_name]}/last_response"
    [[ -f "$_resp_file" ]] || { log "MCP [$_name]: no HTTP response"; return 1; }
    cat "$_resp_file"
    return 0
}

# ── Transport Abstraction ──

mcp_connect_server() {
    local _name="$1" _cfg="${MCP_SERVERS[$_name]}" _transport
    _transport=$(jq -r '.transport // "stdio"' <<< "$_cfg")
    log "DEBUG: [MCP] connect: server=$_name transport=$_transport"
    case "$_transport" in
        stdio) mcp_transport_stdio_connect "$_name" ;;
        sse)   mcp_transport_sse_connect "$_name" ;;
        http)  mcp_transport_http_connect "$_name" ;;
        *)     log "MCP [$_name]: unknown transport $_transport"; return 1 ;;
    esac
}

mcp_send() {
    local _name="$1" _json="$2" _t="${MCP_SERVER_TRANSPORT[$_name]:-stdio}"
    log "DEBUG: MCP send name=$_name transport=$_t size=${#_json}" 2>/dev/null || true
    case "$_t" in
        stdio) mcp_transport_stdio_send "$_name" "$_json" ;;
        sse)   mcp_transport_sse_send "$_name" "$_json" ;;
        http)  mcp_transport_http_send "$_name" "$_json" ;;
    esac
}

mcp_recv() {
    local _name="$1" _timeout="${2:-60}" _t="${MCP_SERVER_TRANSPORT[$_name]:-stdio}"
    log "DEBUG: MCP recv name=$_name transport=$_t timeout=$_timeout" 2>/dev/null || true
    case "$_t" in
        stdio) mcp_transport_stdio_recv "$_name" "$_timeout" ;;
        sse)   mcp_transport_sse_recv "$_name" "$_timeout" ;;
        http)  mcp_transport_http_recv "$_name" "$_timeout" ;;
    esac
}

mcp_disconnect_server() {
    local _name="$1"
    local _pid="${MCP_SERVER_PID[$_name]:-}" _dir="${MCP_SERVER_DIR[$_name]:-}"
    [[ -n "$_pid" ]] && _proc_kill "$_pid" TERM
    rm -f "${TMPDIR:-/tmp}/bashagt_mcp_${BASHAGT_DAEMON_PID:-$$}_${_pid}" 2>/dev/null || true
    [[ -n "$_dir" ]] && rm -rf "$_dir" 2>/dev/null || true
    unset "MCP_SERVER_PID[$_name]" "MCP_SERVER_DIR[$_name]" "MCP_SERVER_TOOLS[$_name]"
    unset "MCP_SERVER_CAPS[$_name]" "MCP_SERVER_READY[$_name]" "MCP_SERVER_URL[$_name]"
    unset "MCP_SERVER_TRANSPORT[$_name]"
}

# ── MCP Protocol Methods ──

mcp_initialize() {
    local _name="$1"
    local _client_caps _request _response
    _client_caps=$(jq -n '{
        protocolVersion: "2024-11-05",
        capabilities: {tools:{}, resources:{subscribe:false}, prompts:{}},
        clientInfo: {name:"bashagt", version:"0.2"}
    }')
    _request=$(mcp_build_request "initialize" "$_client_caps")
    mcp_send "$_name" "$_request" || return 1
    _response=$(mcp_recv "$_name" 15) || return 1

    # Validate
    local _err; _err=$(jq -r '.error.message // ""' <<< "$_response" 2>/dev/null)
    [[ -n "$_err" ]] && { log "MCP [$_name]: init error: $_err"; return 1; }

    local _proto; _proto=$(jq -r '.result.protocolVersion // "unknown"' <<< "$_response" 2>/dev/null)
    MCP_SERVER_CAPS[$_name]=$(jq -c '.result.capabilities // {}' <<< "$_response")
    log "MCP [$_name]: protocol=$_proto"

    # Send initialized notification
    local _notify; _notify=$(mcp_build_notification "notifications/initialized" "{}")
    mcp_send "$_name" "$_notify" || true  # fire-and-forget

    MCP_SERVER_READY[$_name]=1
    return 0
}

mcp_list_tools() {
    local _name="$1" _request _response _tools _count
    _request=$(mcp_build_request "tools/list" "{}")
    mcp_send "$_name" "$_request" || return 1
    _response=$(mcp_recv "$_name" 15) || return 1

    _tools=$(jq -c --arg prefix "mcp__${_name}__" \
        '.result.tools // [] | [.[] | .name = ($prefix + .name)]' <<< "$_response" 2>/dev/null)
    _count=$(jq 'length' <<< "$_tools" 2>/dev/null || echo 0)
    MCP_SERVER_TOOLS[$_name]="$_tools"
    log "MCP [$_name]: $_count tools listed"
    return 0
}

mcp_call_tool() {
    local _name="$1" _tool="$2" _args="${3:-{}}"
    log "DEBUG: [MCP] call_tool: server=$_name tool=$_tool"
    local _params _request _response _content _err
    _params=$(jq -n --arg name "$_tool" --argjson args "$_args" '{name:$name, arguments:$args}')
    _request=$(mcp_build_request "tools/call" "$_params")
    mcp_send "$_name" "$_request" || return 1
    _response=$(mcp_recv "$_name" "${BASHAGT_MCP_REQUEST_TIMEOUT:-60}") || return 1

    _err=$(jq -r '.error.message // ""' <<< "$_response" 2>/dev/null)
    if [[ -n "$_err" ]]; then
        printf 'MCP Error [%s/%s]: %s\n' "$_name" "$_tool" "$_err"
        return 1
    fi

    _content=$(jq -r '.result.content // [] | map(select(.type=="text") | .text) | join("\n")' \
        <<< "$_response" 2>/dev/null)
    [[ -z "$_content" ]] && _content=$(jq -c '.result // {}' <<< "$_response" 2>/dev/null)
    printf '%s' "$_content"
    return 0
}

# ── Tool Integration ──

mcp_build_tools_json() {
    local _name _tools _count _i _tool
    for _name in "${!MCP_SERVER_TOOLS[@]}"; do
        [[ "${MCP_SERVER_READY[$_name]:-0}" != "1" ]] && continue
        _tools="${MCP_SERVER_TOOLS[$_name]}"
        _count=$(jq 'length' <<< "$_tools" 2>/dev/null || echo 0)
        for ((_i=0; _i<_count; _i++)); do
            _tool=$(jq -c ".[$_i]" <<< "$_tools" 2>/dev/null)
            [[ -n "$_tool" ]] && printf '%s\n' "$_tool"
        done
    done
}

mcp_dispatch_tool() {
    local _server="$1" _tool="$2" _input="$3"
    if [[ "${MCP_SERVER_READY[$_server]:-0}" != "1" ]]; then
        printf 'MCP server "%s" is not connected\n' "$_server"
        return 1
    fi
    mcp_call_tool "$_server" "$_tool" "$_input"
}

# ── MCP Init / Shutdown ──

mcp_init() {
    [[ "${BASHAGT_MCP_ENABLED:-true}" != "true" ]] && return 0
    mcp_load_config
    local _svr_count=0
    [[ -v MCP_SERVERS[@] ]] && _svr_count=${#MCP_SERVERS[@]}
    [[ $_svr_count -eq 0 ]] && return 0
    log "DEBUG: [MCP] mcp_init: servers=$_svr_count"

    local _name
    for _name in "${!MCP_SERVERS[@]}"; do
        if mcp_connect_server "$_name"; then
            if mcp_initialize "$_name"; then
                mcp_list_tools "$_name" || log "WARN: MCP [$_name]: tools/list failed"
                MCP_CONNECTED_COUNT=$((MCP_CONNECTED_COUNT + 1))
            fi
        fi
    done
    log "MCP: ${MCP_CONNECTED_COUNT}/${#MCP_SERVERS[@]} server(s) connected"
    return 0
}

mcp_shutdown() {
    local _name _mcp_count=0
    [[ -v MCP_SERVER_PID[@] ]] && _mcp_count=${#MCP_SERVER_PID[@]}
    log "DEBUG: MCP_SHUTDOWN servers=$_mcp_count"
    for _name in "${!MCP_SERVER_PID[@]}"; do
        mcp_disconnect_server "$_name"
    done
    MCP_CONNECTED_COUNT=0
}

# ── Slash Command Handlers ──

cmd_mcp_status() {
    local _mcp_out=''
    _mcp_out+=$(printf '  %sMCP:%s %d/%d server(s) connected' "$BOLD" "$RESET" \
        "${MCP_CONNECTED_COUNT:-0}" "${#MCP_SERVERS[@]}")$'\n'
    local _name
    for _name in "${!MCP_SERVERS[@]}"; do
        local _ready="${MCP_SERVER_READY[$_name]:-0}" _state
        [[ "$_ready" == "1" ]] && _state="$(ui_dot done)" || _state="$(ui_dot error)"
        local _tool_count; _tool_count=$(jq 'length' <<< "${MCP_SERVER_TOOLS[$_name]:-[]}" 2>/dev/null || echo 0)
        _mcp_out+=$(printf '    %s %s' "$_state" "$_name")
        [[ "$_ready" == "1" ]] && _mcp_out+=$(printf ' (%s tools, %s)' "$_tool_count" "${MCP_SERVER_TRANSPORT[$_name]:-stdio}")
        _mcp_out+=$'\n'
    done
    _stream_emit "text" "$(jq -nc --arg c "$_mcp_out" '{content: $c}')"
}

cmd_mcp_list() {
    local _mcp_out=''
    _mcp_out+=$(printf '  %sMCP servers:%s' "$BOLD" "$RESET")$'\n'
    local _name _cfg _transport _cmd _url
    for _name in "${!MCP_SERVERS[@]}"; do
        _cfg="${MCP_SERVERS[$_name]}"
        _transport=$(jq -r '.transport // "stdio"' <<< "$_cfg")
        _cmd=$(jq -r '.command // ""' <<< "$_cfg")
        _url=$(jq -r '.url // ""' <<< "$_cfg")
        _mcp_out+=$(printf '    %s — %s' "$_name" "$_transport")
        [[ -n "$_cmd" ]] && _mcp_out+=$(printf ' [%s]' "$_cmd")
        [[ -n "$_url" ]] && _mcp_out+=$(printf ' [%s]' "$_url")
        _mcp_out+=$'\n'
    done
    _stream_emit "text" "$(jq -nc --arg c "$_mcp_out" '{content: $c}')"
}

cmd_mcp_connect() {
    local _name="${1# }"; _name="${_name%% *}"
    [[ -z "$_name" ]] && { _stream_kv text content '{msg}'; return; }
    [[ -z "${MCP_SERVERS[$_name]:-}" ]] && { _stream_emit "text" "$(jq -nc --arg c "  Unknown server: $_name" '{content: $c}')"; return; }
    [[ "${MCP_SERVER_READY[$_name]:-0}" == "1" ]] && { _stream_emit "text" "$(jq -nc --arg c "  Already connected: $_name" '{content: $c}')"; return; }
    if mcp_connect_server "$_name" && mcp_initialize "$_name" && mcp_list_tools "$_name"; then
        MCP_CONNECTED_COUNT=$((MCP_CONNECTED_COUNT + 1))
        _stream_emit "text" "$(jq -nc --arg c "  $(ui_dot done) Connected: $_name" '{content: $c}')"
    else
        _stream_emit "text" "$(jq -nc --arg c "  $(ui_dot error) Failed to connect: $_name" '{content: $c}')"
    fi
}

cmd_mcp_disconnect() {
    local _name="${1# }"; _name="${_name%% *}"
    [[ -z "$_name" ]] && { _stream_kv text content '{msg}'; return; }
    [[ "${MCP_SERVER_READY[$_name]:-0}" != "1" ]] && { _stream_emit "text" "$(jq -nc --arg c "  Not connected: $_name" '{content: $c}')"; return; }
    mcp_disconnect_server "$_name"
    MCP_CONNECTED_COUNT=$((MCP_CONNECTED_COUNT - 1))
    (( MCP_CONNECTED_COUNT < 0 )) && MCP_CONNECTED_COUNT=0
    _stream_emit "text" "$(jq -nc --arg c "  Disconnected: $_name" '{content: $c}')"
}

cmd_mcp_refresh() {
    local _name _mcp_out=''
    for _name in "${!MCP_SERVER_READY[@]}"; do
        [[ "${MCP_SERVER_READY[$_name]}" == "1" ]] || continue
        mcp_list_tools "$_name" && _mcp_out+=$(printf '  Refreshed: %s' "$_name")$'\n'
    done
    [[ -n "$_mcp_out" ]] && _stream_emit "text" "$(jq -nc --arg c "$_mcp_out" '{content: $c}')"
}

cmd_mcp_tools() {
    local _name="${1# }"; _name="${_name%% *}"
    local _mcp_out=''
    if [[ -n "$_name" ]]; then
        local _tools="${MCP_SERVER_TOOLS[$_name]:-[]}"
        _mcp_out+=$(printf '  %s (%s tools):' "$_name" "$(jq 'length' <<< "$_tools")")$'\n'
        _mcp_out+=$(jq -r '.[] | "    - \(.name // "?") — \(.description // "no description")' <<< "$_tools" 2>/dev/null)$'\n'
    else
        for _name in "${!MCP_SERVER_READY[@]}"; do
            [[ "${MCP_SERVER_READY[$_name]}" == "1" ]] || continue
            local _tools="${MCP_SERVER_TOOLS[$_name]:-[]}"
            _mcp_out+=$(printf '  %s (%s tools):' "$_name" "$(jq 'length' <<< "$_tools")")$'\n'
            _mcp_out+=$(jq -r '.[] | "    - \(.name // "?")"' <<< "$_tools" 2>/dev/null)$'\n'
        done
    fi
    _stream_emit "text" "$(jq -nc --arg c "$_mcp_out" '{content: $c}')"
}

# ============================================================================
# SECTION 11d: Daemon — persistence, subprocess, scheduler, HTTP server
# ============================================================================
# Four-layer architecture:
#   L1: Subprocess Manager (_spm_*)     — controlled fork, reap, shutdown
#   L2: Session Runtime                 — create/delete/execute sessions
#   L3: Gateway (_gw_*)                 — route, validate, standardize errors
#   L4: HTTP Transport                  — nc accept, parse, SSE stream
# Plus: Scheduler (BG2)                — cron timer processing, lifecycle GC

# ── L1: Subprocess Manager ──

declare -A SUBPROC
SUBPROC_COUNT=0
SUBPROC_MAX="${BASHAGT_SUBPROC_MAX:-5}"

_spm_reap() {
    local _pid
    for _pid in "${!SUBPROC[@]}"; do
        if ! kill -0 "$_pid" 2>/dev/null; then
            unset "SUBPROC[$_pid]"
            SUBPROC_COUNT=$((SUBPROC_COUNT - 1))
        fi
    done
    [[ $SUBPROC_COUNT -lt 0 ]] && SUBPROC_COUNT=0
    return 0
}

_spm_spawn() {
    local _type="$1" _sid="$2"; shift 2
    while (( SUBPROC_COUNT >= SUBPROC_MAX )); do
        _spm_reap
        (( SUBPROC_COUNT >= SUBPROC_MAX )) && sleep 0.1
    done
    SUBPROC_COUNT=$((SUBPROC_COUNT + 1))
    local _start; _start=${EPOCHSECONDS:-$(date +%s)}
    (
        "$@"
        exit $?  # _spm_reap handles decrement on next tick
    ) &
    local _pid=$!
    SUBPROC[$_pid]="${_type}|${_sid}|${_start}"
}

# ── Persistent Session Worker Pool (optimization #4) ──

declare -A SESSION_WORKER_PID

_session_ensure_worker() {
    local _sid="$1" _dir="$HOME/.bashagt/sessions/$_sid"
    local _pid="${SESSION_WORKER_PID[$_sid]:-}"
    if [[ -n "$_pid" ]] && kill -0 "$_pid" 2>/dev/null; then
        return 0
    fi
    # Create wakeup FIFO if needed
    local _fifo="$_dir/.wakeup.fifo"
    [[ -p "$_fifo" ]] || mkfifo "$_fifo" 2>/dev/null || return 1
    # Start persistent worker
    ( _session_worker_loop "$_sid" "$_dir" ) &
    SESSION_WORKER_PID[$_sid]=$!
    _proc_register "$!" "worker" "session:$_sid"
}

_session_worker_loop() {
    trap '_pkill_tree $$ KILL 2>/dev/null; exit' TERM INT
    exec 4<&- 2>/dev/null || true  # close inherited read-FIFO write end (fd 4)
    exec 1>/dev/null 2>/dev/null || true  # close inherited read-FIFO write end (fd 1, from >&4)
    local _sid="$1" _dir="$2" _fifo="$_dir/.wakeup.fifo"
    local _last_active; _last_active=${EPOCHSECONDS:-$(date +%s)}
    exec 3<> "$_fifo"
    while true; do
        read -r _ <&3 2>/dev/null || { sleep 0.5; continue; }
        _last_active=${EPOCHSECONDS:-$(date +%s)}
        # Process all pending prompts
        while true; do
            mv "$_dir/inbox.jsonl" "$_dir/inbox.proc.jsonl" 2>/dev/null || break
            local _combined; _combined=$(< "$_dir/inbox.proc.jsonl")
            rm -f "$_dir/inbox.proc.jsonl"
            [[ -z "$_combined" ]] && break
            printf '%s' "$_combined" | BASHAGT_DAEMON_WORKER=1 BASHAGT_PROJECT_DIR="$_dir" "${BASHAGT_BIN:-$0}" --oneshot --stream 2>/dev/null > "$_dir/stream_out.txt"
            printf '{"type":"done","ts":%s}\n' "$(_timestamp_ms)" >> "$_dir/stream_out.txt"
        done
        # Idle timeout: 5 minutes
        local _now; _now=${EPOCHSECONDS:-$(date +%s)}
        (( _now - _last_active > 300 )) && break
    done
    exec 3<&-
}

_worker_shutdown() {
    local _sid _pid
    for _sid in "${!SESSION_WORKER_PID[@]}"; do
        _pid="${SESSION_WORKER_PID[$_sid]}"
        _proc_kill "$_pid"
    done
}

_spm_shutdown() {
    local _pid
    for _pid in "${!SUBPROC[@]}"; do
        _proc_kill "$_pid" TERM
    done
    sleep 2
    for _pid in "${!SUBPROC[@]}"; do
        _proc_kill "$_pid" KILL
        unset "SUBPROC[$_pid]"
    done
    SUBPROC_COUNT=0
}

# ── L2: Session Runtime ──

session_create() {
    local _rand; _rand=$(dd if=/dev/urandom bs=8 count=1 2>/dev/null | base64 2>/dev/null | tr -d '/+=')
    local _id; _id="${EPOCHSECONDS:-$(date +%s)}_${_rand:0:8}"
    [[ -z "$_id" ]] && _id="${EPOCHSECONDS:-$(date +%s)}_$$"  # fallback if /dev/urandom unavailable
    local _dir="$HOME/.bashagt/sessions/$_id"
    mkdir -p "$_dir/.bashagt/"{memory,comm,mem_net/engrams}
    echo "$_id" > "$_dir/.bashagt/session_id"
    printf '%s\n' "${EPOCHSECONDS:-$(date +%s)}" > "$_dir/created_at"
    jq -n --arg now "${EPOCHSECONDS:-$(date +%s)}" '{created:($now|tonumber), last_active:($now|tonumber), label:""}' > "$_dir/meta.json"
    echo "# Session $_id" > "$_dir/.bashagt/BASHAGT.md"
    printf '' > "$_dir/stream_out.txt"   # async response output
    printf '' > "$_dir/inbox.jsonl"      # pending prompts queue
    mkfifo "$_dir/.wakeup.fifo" 2>/dev/null || true  # worker wakeup signal
    printf '%s' "$_id"
}

session_list() {
    local _dir _sid _created _msgs _label
    for _dir in "$HOME/.bashagt/sessions/"*/; do
        [[ -d "$_dir" ]] || continue
        _sid=$(basename "$_dir")
        _created=$(cat "$_dir/created_at" 2>/dev/null || echo "0")
        _msgs=$(jq 'length' "$_dir/.bashagt/history.json" 2>/dev/null || echo 0)
        _label=$(jq -r '.label // ""' "$_dir/meta.json" 2>/dev/null || echo "")
        printf '%s\t%s\t%s\t%s\n' "$_sid" "$_created" "$_msgs" "$_label"
    done
}

session_delete() {
    local _sid="$1" _dir="$HOME/.bashagt/sessions/$_sid"
    [[ -n "${SESSION_WORKER_PID[$_sid]:-}" ]] && _proc_kill "${SESSION_WORKER_PID[$_sid]}"
    unset "SESSION_WORKER_PID[$_sid]" 2>/dev/null || true
    _scheduler_remove_session "$_sid"  # optimization #6
    [[ -d "$_dir" ]] && rm -rf "$_dir"
}

_session_execute() {
    local _sid="$1" _prompt="$2" _dir="$HOME/.bashagt/sessions/$_sid"

    # Update last_active timestamp
    local _now; _now=${EPOCHSECONDS:-$(date +%s)}
    jq --arg now "$_now" '.last_active = ($now | tonumber)' \
        "$_dir/meta.json" > "$_dir/meta.tmp" 2>/dev/null && \
        mv "$_dir/meta.tmp" "$_dir/meta.json" 2>/dev/null || true

    # Execute bashagt in oneshot mode with session directory as project root
    printf '%s' "$_prompt" | BASHAGT_PROJECT_DIR="$_dir" "${BASHAGT_BIN:-$0}" --oneshot --stream 2>/dev/null
}

# ── L3: Gateway Layer ──

_GW_STATUS=0

_gw_status_text() {
    case "$1" in
        200) printf 'OK' ;;  202) printf 'Accepted' ;;  400) printf 'Bad Request' ;;
        404) printf 'Not Found' ;;  405) printf 'Method Not Allowed' ;;
        500) printf 'Internal Server Error' ;;  *) printf 'Unknown' ;;
    esac
}

_gw_json() {
    local _status="$1" _json="$2"
    _GW_STATUS=$_status
    local _text; _text=$(_gw_status_text "$_status")
    printf 'HTTP/1.1 %s %s\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, DELETE, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\nContent-Type: application/json\r\nContent-Length: %s\r\n\r\n%s' \
        "$_status" "$_text" "${#_json}" "$_json"
}

_gw_error() {
    local _status="$1" _msg="$2"
    log "DEBUG: [GW] error: status=$_status msg=$_msg"
    local _json; _json=$(jq -n --arg msg "$_msg" --argjson code "$_status" '{error:$msg, code:$code}')
    _gw_json "$_status" "$_json"
}

_gw_session_create() {
    local _sid; _sid=$(session_create)
    log "DEBUG: [GW] session_create: sid=$_sid"
    _gw_json 200 "{\"session_id\":\"$_sid\"}"
}

# POST /v1/chat — combined session create + first message (single request)
# Eliminates the 2-request race in nc serial mode for hotkey use.
_gw_chat() {
    local _body="$1" _prompt _sid _dir
    _prompt=$(jq -r '.prompt // ""' <<< "$_body" 2>/dev/null)
    [[ -z "$_prompt" ]] && { _gw_error 400 "missing prompt field"; return; }
    _sid=$(session_create)
    log "DEBUG: [GW] chat: sid=$_sid prompt_len=${#_prompt}"
    _dir="$HOME/.bashagt/sessions/$_sid"
    printf '%s\n' "$_prompt" >> "$_dir/inbox.jsonl"
    _session_ensure_worker "$_sid"
    echo "1" > "$_dir/.wakeup.fifo" 2>/dev/null || true
    _gw_json 202 "{\"session_id\":\"$_sid\",\"stream_url\":\"/v1/session/$_sid/stream\"}"
}

_gw_session_list() {
    local _list="[" _first=1 _sid _created _msgs _label
    while IFS=$'\t' read -r _sid _created _msgs _label; do
        [[ -z "$_sid" ]] && continue
        [[ $_first -eq 0 ]] && _list+=","
        _list+="{\"id\":\"$_sid\",\"created\":$_created,\"messages\":$_msgs,\"label\":\"$_label\"}"
        _first=0
    done < <(session_list)
    _list+="]"
    _gw_json 200 "$_list"
}

_gw_session_status() {
    local _sid="$1"
    [[ -z "$_sid" ]] && { _gw_error 400 "missing session id"; return; }
    local _dir="$HOME/.bashagt/sessions/$_sid"
    [[ ! -d "$_dir" ]] && { _gw_error 404 "session not found: $_sid"; return; }
    local _msgs _todos _created _label
    _msgs=$(jq 'length' "$_dir/.bashagt/history.json" 2>/dev/null || echo 0)
    _todos=$(jq 'length' "$_dir/.bashagt/todo.json" 2>/dev/null || echo 0)
    _created=$(cat "$_dir/created_at" 2>/dev/null || echo "0")
    _label=$(jq -r '.label // ""' "$_dir/meta.json" 2>/dev/null || echo "")
    _gw_json 200 "{\"id\":\"$_sid\",\"messages\":$_msgs,\"todos\":$_todos,\"created\":$_created,\"label\":\"$_label\"}"
}

_gw_session_delete() {
    local _sid="$1"
    [[ -z "$_sid" ]] && { _gw_error 400 "missing session id"; return; }
    local _dir="$HOME/.bashagt/sessions/$_sid"
    [[ ! -d "$_dir" ]] && { _gw_error 404 "session not found: $_sid"; return; }
    session_delete "$_sid"
    _gw_json 200 "{\"deleted\":\"$_sid\"}"
}

_gw_session_message() {
    local _sid="$1" _body="$2"
    [[ -z "$_sid" ]] && { _gw_error 400 "missing session id"; return; }
    local _dir="$HOME/.bashagt/sessions/$_sid"
    [[ ! -d "$_dir" ]] && { _gw_error 404 "session not found: $_sid"; return; }
    local _prompt; _prompt=$(jq -r '.prompt // ""' <<< "$_body" 2>/dev/null)
    [[ -z "$_prompt" ]] && { _gw_error 400 "missing prompt field"; return; }
    [[ -x "${BASHAGT_BIN:-$0}" ]] || { _gw_error 500 "bashagt binary not found or not executable"; return; }

    log "DEBUG: [GW] session_message: sid=$_sid prompt_len=${#_prompt}"

    # Async: append prompt to inbox queue, then atomically claim the queue (mv).
    # Persistent worker pool (optimization #4): worker stays alive between requests.
    # POST just writes to inbox and wakes worker; no fork per request.
    printf '%s\n' "$_prompt" >> "$_dir/inbox.jsonl"
    _session_ensure_worker "$_sid"
    echo "1" > "$_dir/.wakeup.fifo" 2>/dev/null || true

    _gw_json 202 "{\"accepted\":true,\"session_id\":\"$_sid\",\"stream_url\":\"/v1/session/$_sid/stream\"}"
}

# POST /v1/session/{id}/respond — resolve a pending human oversight request
_gw_session_respond() {
    local _sid="$1" _body="$2"
    [[ -z "$_sid" ]] && { _gw_error 400 "missing session id"; return; }
    local _dir="$HOME/.bashagt/sessions/$_sid"
    [[ ! -d "$_dir" ]] && { _gw_error 404 "session not found: $_sid"; return; }

    local _rid; _rid=$(jq -r '.request_id // ""' <<< "$_body" 2>/dev/null)
    local _choice; _choice=$(jq -r '.choice // ""' <<< "$_body" 2>/dev/null)
    [[ -z "$_rid" ]] && { _gw_error 400 "missing request_id field"; return; }
    [[ -z "$_choice" && "$_choice" != "0" ]] && { _gw_error 400 "missing choice field"; return; }

    local _req_file="$_dir/request_pending.json"
    [[ ! -f "$_req_file" ]] && { _gw_error 404 "no pending request for this session"; return; }

    # Verify request_id matches
    local _stored_rid
    _stored_rid=$(jq -r '.request_id // ""' "$_req_file" 2>/dev/null)
    [[ "$_stored_rid" != "$_rid" ]] && { _gw_error 400 "request_id mismatch or already resolved"; return; }

    # Mark as resolved
    local _options_json _choice_label _resolved_at
    _resolved_at=$(_timestamp_ms)
    jq --arg choice "$_choice" --argjson ts "$_resolved_at" \
        '.status = "resolved" | .choice = $choice | .resolved_at = $ts' \
        "$_req_file" > "$_req_file.tmp" && mv "$_req_file.tmp" "$_req_file"

    # Resolve choice: numeric index or label string
    _options_json=$(jq -c '.options // []' "$_req_file" 2>/dev/null)
    if [[ "$_choice" =~ ^[0-9]+$ ]]; then
        _choice_label=$(jq -r ".[$_choice] // \"unknown\"" <<< "$_options_json" 2>/dev/null)
    else
        _choice_label="$_choice"
    fi

    log "DEBUG: [GW] session_respond: sid=$_sid rid=$_rid choice=$_choice_label"

    # Check request_type for plan_confirm
    local _req_type
    _req_type=$(jq -r '.request_type // "oversight"' "$_req_file" 2>/dev/null)

    # Format response as user message and inject into inbox
    local _user_msg
    if [[ "$_req_type" == "plan_confirm" ]] && [[ "$_choice" == "0" ]]; then
        # Plan approved: inject marker for worker to extract steps from plan.md
        local _plan_file
        _plan_file=$(jq -r '.plan_file // ".bashagt/plan.md"' "$_req_file" 2>/dev/null)
        _user_msg="__PLAN_APPROVED__
Plan approved by user. Plan file: ${_plan_file}"
    else
        _user_msg="## Human response to oversight request (${_rid})
Selected: ${_choice_label}"
    fi

    printf '%s\n' "$_user_msg" >> "$_dir/inbox.jsonl"

    # Wake worker to process the response
    _session_ensure_worker "$_sid"
    echo "1" > "$_dir/.wakeup.fifo" 2>/dev/null || true

    # Clean up request file
    rm -f "$_req_file"

    _gw_json 200 "$(jq -nc --arg id "$_rid" --arg choice "$_choice_label" \
        '{resolved: true, request_id: $id, choice: $choice}')"
}

# GET /v1/session/{id}/stream — SSE stream of async processing output
_gw_session_stream() {
    local _sid="$1" _dir="$HOME/.bashagt/sessions/$_sid"
    [[ -z "$_sid" ]] && { _gw_error 400 "missing session id"; return; }
    [[ ! -d "$_dir" ]] && { _gw_error 404 "session not found: $_sid"; return; }

    printf 'HTTP/1.1 200 OK\r\nAccess-Control-Allow-Origin: *\r\nContent-Type: text/event-stream\r\n'
    printf 'Cache-Control: no-cache\r\nConnection: keep-alive\r\n\r\n'
    _GW_STATUS=200

    local _fifo; _fifo=$(_mktemp_u /tmp/bashagt_stf.XXXXXX)
    mkfifo "$_fifo" 2>/dev/null || { _gw_error 500 "fifo failed"; return; }
    ( tail -n +1 -f "$_dir/stream_out.txt" 2>/dev/null > "$_fifo" ) &
    local _tpid=$!
    # Ensure cleanup on exit
    trap 'kill $_tpid 2>/dev/null; rm -f "$_fifo"' EXIT

    local _line _has_error=0
    while IFS= read -r _line; do
        printf 'data: %s\n\n' "$_line"
        [[ "$_line" == *'"type":"done"'* ]] && break
        [[ "$_line" == *'"type":"error"'* ]] && _has_error=1
    done < "$_fifo"

    kill $_tpid 2>/dev/null || true
    if (( _has_error )); then
        printf 'event: error\ndata: {}\n\n'
    else
        printf 'event: done\ndata: {}\n\n'
    fi
    rm -f "$_fifo"
    trap - EXIT
}

_gw_cron_manage() {
    local _method="$1" _body="$2"
    local _sid; _sid=$(jq -r '.session_id // ""' <<< "$_body" 2>/dev/null)
    local _dir="$HOME/.bashagt/sessions/$_sid"
    [[ ! -d "$_dir" ]] && { _gw_error 404 "session not found: $_sid"; return; }

    case "$_method" in
        POST)
            local _schedule _prompt _mode
            _schedule=$(jq -r '.schedule // ""' <<< "$_body")
            _prompt=$(jq -r '.prompt // ""' <<< "$_body")
            _mode=$(jq -r '.mode // "recurring"' <<< "$_body")
            [[ -z "$_schedule" || -z "$_prompt" ]] && { _gw_error 400 "missing schedule or prompt"; return; }
            local _jid; _jid="cron_${EPOCHSECONDS:-$(date +%s)}_$RANDOM"
            local _next; _next=$(_cron_next "$_schedule" "${EPOCHSECONDS:-$(date +%s)}")
            [[ -z "$_next" ]] && { _gw_error 400 "invalid cron schedule: $_schedule"; return; }
            local _job; _job=$(jq -n --arg id "$_jid" --arg sched "$_schedule" \
                --arg prompt "$_prompt" --arg mode "$_mode" --argjson next "$_next" \
                '{id:$id, schedule:$sched, prompt:$prompt, mode:$mode, next_fire:$next, last_fire:0, enabled:true}')
            # Append to cron.json
            local _cron_file="$_dir/cron.json"
            if [[ -f "$_cron_file" ]]; then
                jq --argjson job "$_job" '. + [$job]' "$_cron_file" > "$_cron_file.tmp" && mv "$_cron_file.tmp" "$_cron_file"
            else
                jq -n --argjson job "$_job" '[$job]' > "$_cron_file"
            fi
            _scheduler_insert "$_sid" "$_job"  # incremental (opt #6)
            _gw_json 200 "{\"cron_id\":\"$_jid\",\"next_fire\":$_next}"
            ;;
        DELETE)
            local _jid; _jid=$(jq -r '.cron_id // ""' <<< "$_body")
            [[ -z "$_jid" ]] && { _gw_error 400 "missing cron_id"; return; }
            local _cron_file="$_dir/cron.json"
            [[ -f "$_cron_file" ]] && jq --arg id "$_jid" 'del(.[] | select(.id == $id))' "$_cron_file" > "$_cron_file.tmp" && mv "$_cron_file.tmp" "$_cron_file"
            _scheduler_remove "$_sid" "$_jid"  # incremental (opt #6)
            _gw_json 200 "{\"deleted\":\"$_jid\"}"
            ;;
        *) _gw_error 405 "method not allowed" ;;
    esac
}

_gw_index_html() {
    cat <<'ENDOFHTML'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>bashagt</title>
<style>
  * { margin:0; padding:0; box-sizing:border-box; }
  body { background:#0d1117; display:flex; justify-content:center; align-items:center;
         min-height:100vh; font-family:"Cascadia Code","Fira Code","JetBrains Mono",
         "SF Mono",Consolas,monospace; }
  .term { width:min(720px,94vw); background:#0d1117; border:1px solid #30363d;
          border-radius:8px; overflow:hidden; }
  .term-bar { background:#161b22; padding:10px 16px; border-bottom:1px solid #30363d;
              display:flex; align-items:center; gap:8px; }
  .term-dot { width:12px; height:12px; border-radius:50%; }
  .term-dot.r { background:#ff5f56; } .term-dot.y { background:#ffbd2e; } .term-dot.g { background:#27c93f; }
  .term-title { flex:1; text-align:center; font-size:12px; color:#8b949e; }
  .term-body { padding:28px 24px; font-size:13px; line-height:1.65; white-space:pre; }
  .c-cyan { color:#20c0e0; } .c-gray { color:#8b949e; } .c-lgray { color:#6e7681; }
  .c-green { color:#3fb950; } .c-highlight { color:#58a6ff; } .c-yellow { color:#d2991d; }
  .bold { font-weight:bold; }
  a { color:#58a6ff; text-decoration:none; } a:hover { text-decoration:underline; }
  @media (max-width:600px) { .term-body { font-size:8px; padding:16px 8px; } }
</style>
</head>
<body>
<div class="term">
  <div class="term-bar">
    <span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
    <span class="term-title">bashagt</span>
  </div>
  <div class="term-body"><span class="c-cyan bold">██████╗  █████╗ ███████╗██╗  ██╗ █████╗  ██████╗ ████████╗</span>
<span class="c-cyan bold">██╔══██╗██╔══██╗██╔════╝██║  ██║██╔══██╗██╔════╝ ╚══██╔══╝</span>
<span class="c-cyan bold">██████╔╝███████║███████╗███████║███████║██║  ███╗   ██║</span>
<span class="c-cyan bold">██╔══██╗██╔══██║╚════██║██╔══██║██╔══██║██║   ██║   ██║</span>
<span class="c-cyan bold">██████╔╝██║  ██║███████║██║  ██║██║  ██║╚██████╔╝   ██║</span>
<span class="c-cyan bold">╚═════╝ ╚═╝  ╚═╝╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝ ╚═════╝    ╚═╝</span>

<span class="c-gray">[bashagt]</span> <span class="c-yellow">LLM agent kernel in pure bash</span>

<span class="c-gray">[bashagt]</span> <span class="c-yellow">daemon listening on</span> <a href="/v1/session/new">http://localhost:9655</a>

<span class="c-lgray">API  </span><span class="c-highlight">POST</span> /v1/chat                <span class="c-lgray">create + send (single req)</span>
<span class="c-lgray">     </span><span class="c-highlight">POST</span> /v1/session/new         <span class="c-lgray">create session</span>
<span class="c-lgray">     </span><span class="c-highlight">POST</span> /v1/session/{id}      <span class="c-lgray">send prompt (async)</span>
<span class="c-lgray">     </span><span class="c-highlight">GET</span>  /v1/session/{id}/stream <span class="c-lgray">SSE stream output</span>
<span class="c-lgray">     </span><span class="c-highlight">GET</span>  /v1/session/{id}      <span class="c-lgray">session status</span>
	<span class="c-lgray">     </span><span class="c-highlight">POST</span> /v1/session/{id}/respond <span class="c-lgray">resolve oversight request</span></div>
</div>
</body>
</html>
ENDOFHTML
}

_gw_index() {
    local _html_file="${BASHAGT_HTML_FILE:-$HOME/.bashagt/webui.html}"
    local _html
    if [[ -f "$_html_file" ]]; then
        _html=$(< "$_html_file")
    else
        _html=$(_gw_index_html)
    fi
    _GW_STATUS=200
    printf 'HTTP/1.1 200 OK\r\nAccess-Control-Allow-Origin: *\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: %s\r\n\r\n%s' \
        "${#_html}" "$_html"
}

_gateway_handle() {
    local _method="$1" _path="$2" _headers="$3" _body="$4"
    local _start; _start=$(_timestamp_ms)

    # Extract session ID for logging (used by access_log below)
    local _sid="-"
    if [[ "$_path" == /v1/session/* ]]; then
        _sid="${_path#/v1/session/}"; _sid="${_sid%%/*}"
    fi

    log "DEBUG: [GW] route: $_method $_path"

    # CORS preflight
    if [[ "$_method" == "OPTIONS" ]]; then
        printf 'HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, DELETE, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\nContent-Length: 0\r\n\r\n'
        _GW_STATUS=204
        return 0
    fi

    case "$_path" in
        /v1/session/new)
            [[ "$_method" != "POST" ]] && { _gw_error 405 "method not allowed"; return; }
            _gw_session_create ;;
        /v1/chat)
            [[ "$_method" != "POST" ]] && { _gw_error 405 "method not allowed"; return; }
            _gw_chat "$_body" ;;
        /v1/sessions)
            [[ "$_method" != "GET" ]] && { _gw_error 405 "method not allowed"; return; }
            _gw_session_list ;;
        /v1/session/*/stream)
            local _sid="${_path#/v1/session/}"; _sid="${_sid%%/stream}"
            [[ "$_method" != "GET" ]] && { _gw_error 405 "method not allowed"; return; }
            _gw_session_stream "$_sid" ;;
        /v1/session/*/respond)
            local _sid="${_path#/v1/session/}"; _sid="${_sid%%/respond}"
            [[ "$_method" != "POST" ]] && { _gw_error 405 "method not allowed"; return; }
            _gw_session_respond "$_sid" "$_body" ;;
        /v1/session/*)
            local _sid="${_path#/v1/session/}"; _sid="${_sid%%/*}"
            case "$_method" in
                POST)   _gw_session_message "$_sid" "$_body" ;;
                GET)    _gw_session_status "$_sid" ;;
                DELETE) _gw_session_delete "$_sid" ;;
                *)      _gw_error 405 "method not allowed" ;;
            esac ;;
        /v1/cron)
            _gw_cron_manage "$_method" "$_body" ;;
        /)
            [[ "$_method" != "GET" ]] && { _gw_error 405 "method not allowed"; return; }
            _gw_index ;;
        *)
            _gw_error 404 "not found" ;;
    esac

    local _elapsed; _elapsed=$(($(_timestamp_ms) - _start))
    access_log "$_method" "$_path" "${_GW_STATUS:-0}" "$_elapsed" "$_sid"
}

# ── L4: HTTP Transport ──

_parse_http_request() {
    _method="" _path="" _headers="" _body=""
    local _line _content_length=0

    IFS= read -r _line || return 1
    _method="${_line%% *}"
    _path="${_line#* }"; _path="${_path%% *}"

    while IFS= read -r _line; do
        [[ "$_line" == $'\r' || -z "$_line" ]] && break
        _headers+="$_line"$'\n'
        [[ "$_line" =~ ^[Cc]ontent-[Ll]ength:[[:space:]]*([0-9]+) ]] && _content_length="${BASH_REMATCH[1]}"
    done

    if (( _content_length > 0 )); then
        # Use dd for byte-exact read — read -n counts chars not bytes
        _body=$(dd bs="$_content_length" count=1 2>/dev/null)
    fi
    log "DEBUG: [GW] request: $_method $_path cl=$_content_length"
}

_http_handler() {
    local _method _path _headers _body
    log "DEBUG: [GW] http_handler: transport=$DAEMON_TRANSPORT"
    if ! _parse_http_request; then
        printf 'HTTP/1.1 400 Bad Request\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: 0\r\n\r\n'
        return
    fi
    _gateway_handle "$_method" "$_path" "$_headers" "$_body"
}

# ── Runtime self-test: can nc do parallel accept (SO_REUSEADDR)? ──
# Starts 2 nc listeners on the same port; nc1 gets a client to enter
# ESTABLISHED state, then checks if nc2 can bind simultaneously.
# Returns 0 if parallel accept is supported, 1 otherwise.
_nc_can_parallel() {
    local _port="${1:-9655}" _wf1 _rf1 _wf2 _rf2 _nc1 _nc2 _ok=0
    _wf1=$(_mktemp_u /tmp/bashagt_pt_w1.XXXXXX)
    _rf1=$(_mktemp_u /tmp/bashagt_pt_r1.XXXXXX)
    _wf2=$(_mktemp_u /tmp/bashagt_pt_w2.XXXXXX)
    _rf2=$(_mktemp_u /tmp/bashagt_pt_r2.XXXXXX)
    mkfifo "$_wf1" "$_rf1" "$_wf2" "$_rf2" 2>/dev/null || return 1
    exec 98<>"$_wf1" 2>/dev/null; exec 99<>"$_rf1" 2>/dev/null
    exec 96<>"$_wf2" 2>/dev/null; exec 97<>"$_rf2" 2>/dev/null

    "${NC_LISTEN[@]}" "$_port" -w 2 <&99 >&98 2>/dev/null &
    _nc1=$!
    sleep 0.03
    # Quick client to get nc1 into ESTABLISHED (proves port can be shared)
    (echo "pt"; sleep 0.3) | ${TIMEOUT_CMD:-timeout} 2 nc localhost "$_port" >/dev/null 2>&1 &
    sleep 0.05

    "${NC_LISTEN[@]}" "$_port" -w 1 <&97 >&96 2>/dev/null &
    _nc2=$!
    sleep 0.03
    kill -0 $_nc2 2>/dev/null && _ok=1

    kill $_nc1 $_nc2 2>/dev/null || true
    wait $_nc1 $_nc2 2>/dev/null || true
    exec 98<&- 99<&- 96<&- 97<&- 2>/dev/null
    rm -f "$_wf1" "$_rf1" "$_wf2" "$_rf2"
    _port_kill "$_port"
    return $(( 1 - _ok ))
}

_daemon_accept_loop() {
    local _port="${BASHAGT_DAEMON_PORT:-9655}"

    case "${DAEMON_TRANSPORT:-nc}" in
        socat)
            log "HTTP accept: socat TCP-LISTEN:$_port (parallel, forking)"
            trap 'exit' TERM INT
            socat TCP-LISTEN:"$_port",reuseaddr,fork SYSTEM:"${BASHAGT_BIN:-$0} --http-handler" 2>/dev/null
            ;;
        *)
            # ── Self-test: can nc do parallel accept (SO_REUSEADDR)? ──
            local _use_parallel=0
            if _nc_can_parallel "$_port"; then
                _use_parallel=1
                log "HTTP accept: nc -l $_port (parallel, per-connection FIFOs)"
            else
                log "HTTP accept: nc -l $_port (serial, self-healing)"
            fi
            trap '_pkill_tree $$ KILL 2>/dev/null; exit' TERM INT

            if (( _use_parallel )); then
                # ── Parallel mode: pre-fork pool of nc listeners.
                # SO_REUSEADDR allows multiple LISTEN on same port. Maintain a
                # small pool of listeners; when one accepts, spawn replacement.
                # Zero dead-window: there's always a listener ready. ──
                local _pwf _prf _pncpid _pool_size=0 _pool_min=3
                while true; do
                    # Count active nc listeners on our port
                    _pool_size=$( { ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null || lsof -i "tcp:$_port" -sTCP:LISTEN 2>/dev/null; } | grep -c ":${_port}" 2>/dev/null || echo 0)

                    if (( _pool_size >= _pool_min )); then
                        sleep 0.05  # pool full, brief wait
                        continue
                    fi

                    _pwf=$(_mktemp_u /tmp/bashagt_w.XXXXXX)
                    _prf=$(_mktemp_u /tmp/bashagt_r.XXXXXX)
                    mkfifo "$_pwf" "$_prf" 2>/dev/null || continue
                    exec 3<>"$_pwf" 2>/dev/null || { rm -f "$_pwf" "$_prf"; continue; }
                    exec 4<>"$_prf" 2>/dev/null || { exec 3<&-; rm -f "$_pwf" "$_prf"; continue; }

                    # Handler runs independently, cleans its own FIFOs
                    ( _http_handler <&3 >&4 2>/dev/null
                      exec 3<&- 4<&- 2>/dev/null
                      rm -f "$_pwf" "$_prf" ) &
                    local _hpid=$!
                    # Safety: kill stuck handler after 30s
                    ( sleep 30; kill -0 $_hpid 2>/dev/null && _pkill_tree $_hpid KILL 2>/dev/null || true ) &

                    # nc binds (SO_REUSEADDR lets it steal LISTEN role).
                    # Background immediately — don't wait.
                    "${NC_LISTEN[@]}" "$_port" -w 600 <&4 >&3 2>/dev/null &
                    _pncpid=$!

                    # Close parent fds (handler and nc keep their own copies)
                    exec 3<&- 2>/dev/null
                    exec 4<&- 2>/dev/null
                    sleep 0.02  # let nc bind before next ss check
                done
            else
                # ── Serial mode: single FIFO pair, self-healing bind ──
                local _wf _rf
                _wf=$(_mktemp_u /tmp/bashagt_w.XXXXXX)
                _rf=$(_mktemp_u /tmp/bashagt_r.XXXXXX)
                mkfifo "$_wf" "$_rf" 2>/dev/null || { log "FATAL: cannot create accept FIFOs"; return 1; }
                trap '_pkill_children $$ KILL 2>/dev/null; rm -f "$_wf" "$_rf"; exit' TERM INT
                local _bind_failures=0
                while true; do
                    exec 3<> "$_wf"; exec 4<> "$_rf" 2>/dev/null || continue
                    ( _http_handler <&3 >&4 ) 2>/dev/null &
                    local _hpid=$!
                    "${NC_LISTEN[@]}" "$_port" -w 600 <&4 >&3 2>/dev/null &
                    local _ncpid=$!
                    sleep 0.15
                    if ! kill -0 $_ncpid 2>/dev/null; then
                        _bind_failures=$((_bind_failures + 1))
                        if (( _bind_failures > 10 )); then
                            log "FATAL: nc failed to bind $_port after 10 retries"
                            exec 3<&- 2>/dev/null || true; exec 4<&- 2>/dev/null || true
                            kill $_hpid 2>/dev/null || true
                            break
                        fi
                        local _backoff=$(( 100 * (2 ** (_bind_failures - 1)) ))
                        (( _backoff > 5000 )) && _backoff=5000
                        log "WARN: nc bind failure #$_bind_failures, retry in ${_backoff}ms"
                        exec 3<&- 2>/dev/null || true; exec 4<&- 2>/dev/null || true
                        kill $_hpid 2>/dev/null || true
                        local _sleep_sec; _sleep_sec=$(awk "BEGIN {printf \"%.2f\", $_backoff/1000}" 2>/dev/null) || _sleep_sec=1
                    sleep "$_sleep_sec" 2>/dev/null || sleep 1
                        continue
                    fi
                    _bind_failures=0
                    wait $_hpid 2>/dev/null || true
                    exec 4<&- 2>/dev/null || true
                    kill $_ncpid 2>/dev/null || true
                    wait $_ncpid 2>/dev/null || true
                    exec 3<&- 2>/dev/null || true
                done
            fi
            ;;
    esac
}

# ── Cron Parser ──

# Parse a single cron field: "*", "*/N", "N", "N,M,O". Returns 0 if matches.
_cron_match_field() {
    local _field="$1" _val="$2"
    [[ "$_field" == "*" ]] && return 0
    # */N pattern: match when val is divisible by step (0, N, 2N, ...)
    if [[ "$_field" == *"/"* ]]; then
        local _step="${_field#*/}"
        (( _val % _step == 0 )) && return 0 || return 1
    fi
    # Comma-separated list
    if [[ "$_field" == *","* ]]; then
        local _part; IFS=',' read -ra _parts <<< "$_field"
        for _part in "${_parts[@]}"; do
            [[ "$_part" == "$_val" ]] && return 0
        done
        return 1
    fi
    [[ "$_field" == "$_val" ]] && return 0
    return 1
}

_cron_next() {
    local _sched="$1" _from="${2:-${EPOCHSECONDS:-$(date +%s)}}"
    local _min _hour _dom _mon _dow
    read -r _min _hour _dom _mon _dow <<< "$_sched"

    local _ts=$(( _from / 60 * 60 + 60 ))  # start at next minute
    local _limit=$(( _from + 365 * 86400 ))  # search up to 1 year ahead

    while (( _ts < _limit )); do
        local _cm _ch _cd _cM _cW
        _cm=$(( (_ts / 60) % 60 ))
        _ch=$(( (_ts / 3600) % 24 ))
        # Approximate day-of-month (1-31) and month (1-12) from epoch
        local _days; _days=$(( _ts / 86400 ))
        # Simple: use date command if available
        if command -v date >/dev/null 2>&1; then
            _cd=$(_date_from_epoch "$_ts" '+%d' | sed 's/^0//')
            _cM=$(_date_from_epoch "$_ts" '+%m' | sed 's/^0//')
            _cW=$(( $(_date_from_epoch "$_ts" '+%w') ))
        else
            # Fallback: approximate (not precise for DST, but functional)
            _cd=$(( (_days % 365) % 31 + 1 ))
            _cM=$(( (_days % 365) / 31 + 1 ))
            _cW=$(( (_days + 4) % 7 ))
        fi

        _cron_match_field "$_min" "$_cm" || { _ts=$((_ts + 60)); continue; }
        _cron_match_field "$_hour" "$_ch" || { _ts=$((_ts + 3600)); continue; }
        _cron_match_field "$_dom" "$_cd" || { _ts=$((_ts + 86400)); continue; }
        _cron_match_field "$_mon" "$_cM" || { _ts=$((_ts + 86400 * 31)); continue; }
        _cron_match_field "$_dow" "$_cW" || { _ts=$((_ts + 86400)); continue; }

        echo "$_ts"
        return 0
    done
    return 1
}

# ── Scheduler (BG2) ──

SCHEDULER_QUEUE="[]"
_LAST_TICK=0

_scheduler_scan() {
    local _jobs="[" _first=1 _dir _sid
    for _dir in "$HOME/.bashagt/sessions/"*/; do
        [[ -d "$_dir" ]] || continue
        _sid=$(basename "$_dir")
        local _cron_file="$_dir/cron.json"
        [[ -f "$_cron_file" ]] || continue
        # Process substitution avoids pipe subshell (compatible with bash < 4.2)
        while IFS= read -r _job; do
            [[ $_first -eq 0 ]] && _jobs+=","
            _jobs+="$_job"; _first=0
        done < <(jq -c --arg sid "$_sid" '.[] | select(.enabled != false) | {sid:$sid} + .' \
            "$_cron_file" 2>/dev/null)
    done
    _jobs+="]"
    SCHEDULER_QUEUE="$_jobs"
}

_scheduler_next_wait_ms() {
    local _now; _now=${EPOCHSECONDS:-$(date +%s)}
    local _next; _next=$(jq -r --argjson now "$_now" \
        '[.[] | .next_fire // empty | tonumber] | min // empty' \
        <<< "$SCHEDULER_QUEUE" 2>/dev/null)
    [[ -z "$_next" ]] && { printf '60000'; return; }
    local _wait=$(( (_next - _now) * 1000 ))
    [[ $_wait -lt 0 ]] && _wait=0
    printf '%d' "$_wait"
}

_scheduler_update_next() {
    local _sid="$1" _jid="$2" _next="$3"
    local _cron_file="$HOME/.bashagt/sessions/$_sid/cron.json"
    [[ -f "$_cron_file" ]] || return
    jq --arg id "$_jid" --argjson next "$_next" \
        'map(if .id == $id then .next_fire = $next | .last_fire = (now | floor) else . end)' \
        "$_cron_file" > "$_cron_file.tmp" && mv "$_cron_file.tmp" "$_cron_file"
}

_scheduler_disable() {
    local _sid="$1" _jid="$2"
    local _cron_file="$HOME/.bashagt/sessions/$_sid/cron.json"
    [[ -f "$_cron_file" ]] || return
    jq --arg id "$_jid" 'map(if .id == $id then .enabled = false else . end)' \
        "$_cron_file" > "$_cron_file.tmp" && mv "$_cron_file.tmp" "$_cron_file"
}

_scheduler_fire_job() {
    local _sid="$1" _jid="$2" _prompt="$3"
    _spm_spawn "cron" "$_sid" _session_execute "$_sid" "$_prompt"
}

_scheduler_process_due() {
    local _now; _now=${EPOCHSECONDS:-$(date +%s)}
    # Process substitution avoids pipe subshell (SPM globals must survive)
    while IFS= read -r _job; do
        local _sid _jid _prompt _mode _schedule
        _sid=$(jq -r '.sid // ""' <<< "$_job")
        _jid=$(jq -r '.id // ""' <<< "$_job")
        _prompt=$(jq -r '.prompt // ""' <<< "$_job")
        _mode=$(jq -r '.mode // "recurring"' <<< "$_job")
        _schedule=$(jq -r '.schedule // ""' <<< "$_job")
        [[ -z "$_sid" || -z "$_prompt" ]] && continue

        log "DEBUG: [DAEMON] cron_fire: job=$_jid sid=$_sid prompt=\"${_prompt:0:50}\""
        _scheduler_fire_job "$_sid" "$_jid" "$_prompt"

        if [[ "$_mode" == "recurring" ]]; then
            local _next; _next=$(_cron_next "$_schedule" "$_now")
            if [[ -n "$_next" ]]; then
                _scheduler_update_next "$_sid" "$_jid" "$_next"
                _scheduler_persist_session "$_sid"
            fi
        else
            _scheduler_disable "$_sid" "$_jid"
            _scheduler_persist_session "$_sid"
        fi
    done < <(jq -c --argjson now "$_now" '.[] | select(.next_fire <= $now)' \
        <<< "$SCHEDULER_QUEUE" 2>/dev/null)
}

_lifecycle_tick_impl() {
    local _now _sid _dir _last
    _now=${EPOCHSECONDS:-$(date +%s)}
    for _dir in "$HOME/.bashagt/sessions/"*/; do
        [[ -d "$_dir" ]] || continue
        _sid=$(basename "$_dir")
        _last=$(_file_mtime "$_dir/meta.json")
        if (( _now - _last > 604800 )); then
            log "GC: removing inactive session $_sid"
            log "DEBUG: [DAEMON] lifecycle_gc: expired=$_sid age=$(( (_now - _last) / 86400 ))d"
            rm -rf "$_dir"
        fi
    done
}

_lifecycle_tick() {
    local _now; _now=${EPOCHSECONDS:-$(date +%s)}
    [[ -z "$_LAST_TICK" ]] && _LAST_TICK=0
    (( _now - _LAST_TICK < 10 )) && return
    _LAST_TICK=$_now
    _spm_spawn "lifecycle" "" _lifecycle_tick_impl
}

# ── Scheduler Incremental (optimization #6) ──

_scheduler_insert() {
    local _sid="$1" _job="$2"
    SCHEDULER_QUEUE=$(jq --arg sid "$_sid" --argjson job "$_job" \
        '. + [{sid:$sid} + $job]' <<< "$SCHEDULER_QUEUE")
}

_scheduler_remove() {
    local _sid="$1" _jid="$2"
    SCHEDULER_QUEUE=$(jq --arg sid "$_sid" --arg jid "$_jid" \
        'map(select(.sid != $sid or .id != $jid))' <<< "$SCHEDULER_QUEUE")
}

_scheduler_remove_session() {
    local _sid="$1"
    SCHEDULER_QUEUE=$(jq --arg sid "$_sid" 'map(select(.sid != $sid))' <<< "$SCHEDULER_QUEUE")
}

_scheduler_persist_session() {
    local _sid="$1" _cron_file="$HOME/.bashagt/sessions/$_sid/cron.json"
    jq --arg sid "$_sid" 'map(select(.sid == $sid) | del(.sid))' <<< "$SCHEDULER_QUEUE" > "$_cron_file.tmp" 2>/dev/null \
        && mv "$_cron_file.tmp" "$_cron_file" 2>/dev/null || true
}

_LAST_RECONCILE=0
_scheduler_reconcile() {
    local _now; _now=${EPOCHSECONDS:-$(date +%s)}
    (( _now - _LAST_RECONCILE < 300 )) && return
    _LAST_RECONCILE=$_now
    _scheduler_scan  # full rebuild every 5 min for consistency
}

_daemon_scheduler() {
    trap '_pkill_tree $$ KILL 2>/dev/null; exit' TERM INT
    _scheduler_scan  # initial full scan
    while true; do
        _scheduler_process_due
        _scheduler_reconcile  # incremental, full rebuild every 5 min
        log "DEBUG: [DAEMON] scheduler_tick: sessions=$(find "$HOME/.bashagt/sessions/" -maxdepth 1 -type d 2>/dev/null | wc -l)"
        _lifecycle_tick
        _spm_reap
        local _wait_ms; _wait_ms=$(_scheduler_next_wait_ms)
        [[ -z "$_wait_ms" || "$_wait_ms" -gt 60000 ]] && _wait_ms=60000
        [[ "$_wait_ms" -lt 200 ]] && _wait_ms=200
        local _sleep_sec; _sleep_sec=$(awk "BEGIN {printf \"%.3f\", $_wait_ms/1000}" 2>/dev/null) || _sleep_sec=1
        sleep "$_sleep_sec" 2>/dev/null || sleep 1
    done
}

# ── Daemon Cleanup (shared by trap and normal exit) ──

_DAEMON_CLEANED=0
_daemon_cleanup() {
    (( _DAEMON_CLEANED )) && return
    _DAEMON_CLEANED=1
    local _port="${1:-9655}"

    # Ignore TERM during cleanup to prevent recursive trap firing
    trap '' TERM

    log "DEBUG: [DAEMON] daemon_cleanup: port=$_port phase1=workers+spm+mcp"

    # Phase 1: graceful shutdown of tracked resources
    _worker_shutdown 2>/dev/null || true
    _spm_shutdown 2>/dev/null || true
    mcp_shutdown 2>/dev/null || true

    # Phase 2: kill daemon sub-processes. Use KILL (not TERM) because
    # nc ignores TERM when blocked on accept(). Kill children first,
    # then the parents, to prevent reparenting to init.
    local _pid
    for _pid in ${ACCEPT_PID:-} ${SCHED_PID:-}; do
        [[ -n "$_pid" ]] || continue
        _pkill_tree "$_pid" KILL 2>/dev/null || true
    done
    sleep 0.5

    # Phase 3: release TCP port and clean up all FIFOs
    _port_kill "$_port"
    rm -f "${TMPDIR:-/tmp}"/bashagt_w.* "${TMPDIR:-/tmp}"/bashagt_r.* "${TMPDIR:-/tmp}"/bashagt_kb.* "${TMPDIR:-/tmp}"/bashagt_stf.* "${TMPDIR:-/tmp}"/bashagt_pt_* 2>/dev/null || true

    # Phase 4: KILL any survivors in the process group (belt and suspenders)
    kill -KILL -- -$$ 2>/dev/null || true
    # Kill tracked process groups (setsid orphans from tool_bash / agent_worker_bash)
    local _pgid _pgid_f
    for _pgid_f in "${TMPDIR:-/tmp}"/bashagt_pgid_${$}_*; do
        [[ -f "$_pgid_f" ]] || continue
        _pgid="${_pgid_f##*_}"
        kill -KILL -- -"$_pgid" 2>/dev/null || true
        rm -f "$_pgid_f"
    done
    # Also kill any orphans that escaped the process group
    for _pid in $(_pgrep_safe 'bashagt.*(--run|--oneshot|--http-handler)'); do
        [[ "$_pid" == "$$" ]] && continue
        kill -9 "$_pid" 2>/dev/null || true
    done
}

# ── Daemon Main ──

# ── Pre-start orphan cleanup: kill stale processes from previous runs ──
_prestart_cleanup() {
    local _port="${1:-9655}"
    _port_kill "$_port"
    local _pid _ppid
    for _pid in $(pgrep -f 'bashagt.*(--run|--oneshot|--http-handler|--debug)' 2>/dev/null); do
        [[ "$_pid" == "$$" ]] && continue  # don't kill ourselves
        _ppid=$(ps -o ppid= -p "$_pid" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' || true)
        [[ "$_ppid" == "1" || "$_ppid" == "${INIT_PID:-1}" ]] && kill -9 "$_pid" 2>/dev/null || true
    done
    # Kill stale PGID tracking files and MCP PID files from crashed sessions
    local _f _base _dpid _pgid
    for _f in "${TMPDIR:-/tmp}"/bashagt_pgid_*_* "${TMPDIR:-/tmp}"/bashagt_mcp_*_*; do
        [[ -f "$_f" ]] || continue
        _base="${_f##*/bashagt_}"
        _base="${_base#pgid_}"
        _base="${_base#mcp_}"
        _dpid="${_base%%_*}"
        _pgid="${_base#*_}"
        if ! kill -0 "$_dpid" 2>/dev/null; then
            kill -KILL -- -"$_pgid" 2>/dev/null || true
            _pkill_tree "$_pgid" KILL 2>/dev/null || true
        fi
        rm -f "$_f"
    done
    sleep 0.2
}

_daemon_main() {
    mkdir -p "$HOME/.bashagt/sessions"
    local _port="${BASHAGT_DAEMON_PORT:-9655}"
    export BASHAGT_DAEMON_PID=$$

    # Pre-flight: kill orphans then check port
    _prestart_cleanup "$_port"
    if _port_is_busy "$_port"; then
        printf '[bashagt] ERROR: port %s is already in use\n' "$_port" >&2
        exit 1
    fi

    log "bashagt daemon starting on port $_port (PID $$)"
    log "DEBUG: [DAEMON] daemon_start: port=$_port transport=$DAEMON_TRANSPORT pid=$$"
    printf 'bashagt — http://localhost:%s (PID %s)\n' "$_port" "$$"

    _daemon_accept_loop &
    ACCEPT_PID=$!
    _daemon_scheduler &
    SCHED_PID=$!

    # Verify accept loop started and bound the port
    sleep 0.3
    if ! kill -0 $ACCEPT_PID 2>/dev/null; then
        printf '[bashagt] ERROR: failed to bind port %s (accept loop died)\n' "$_port" >&2
        kill $SCHED_PID 2>/dev/null || true
        exit 1
    fi

    trap '_daemon_cleanup '"$_port"'; exit 0' INT TERM
    trap '_daemon_cleanup '"$_port"'' EXIT

    wait
    _daemon_cleanup "$_port"
    trap - EXIT INT TERM
}

# ============================================================================
# SECTION 12: Main
# ============================================================================

# ── Set project paths based on BASHAGT_PROJECT_DIR ──
_set_project_paths() {
    local _base="${BASHAGT_PROJECT_DIR:-.}"
    BASHAGT_HISTORY_FILE="${_base}/.bashagt/history.json"
    TODO_FILE="${_base}/.bashagt/todo.json"
    MEM_NET_DIR="${_base}/.bashagt/mem_net"
    COMM_DIR="${_base}/.bashagt/comm"
}

main() {
    _tm "main_start"
    # ── CLI flag parsing ──
    BASHAGT_MODE="${BASHAGT_MODE:-interactive}"
    BASHAGT_SESSION_ID="${BASHAGT_SESSION_ID:-}"
    BASHAGT_STREAM_MODE=1
    BASHAGT_OE_RAW="${BASHAGT_OE_RAW:-0}"

    while (($# > 0)); do
        case "$1" in
            --session)  BASHAGT_SESSION_ID="$2"
                        BASHAGT_PROJECT_DIR="$HOME/.bashagt/sessions/$2"; shift 2 ;;
            --oneshot)  BASHAGT_MODE="oneshot"; shift ;;
            --stream)   BASHAGT_OE_RAW=1; shift ;;
            --install)  BASHAGT_MODE="install"; shift ;;
            --uninstall) BASHAGT_MODE="uninstall"; shift ;;
            --update)    BASHAGT_MODE="update"; shift ;;
            --run)       BASHAGT_MODE="run"; shift ;;
            --debug)     BASHAGT_DEBUG=1; shift ;;
            --http-handler) BASHAGT_MODE="http_handler"; shift ;;
            --port)     BASHAGT_DAEMON_PORT="$2"; shift 2 ;;
            --project-dir) BASHAGT_PROJECT_DIR="$2"; shift 2 ;;
            *) shift ;;
        esac
    done

    # ── Logging subsystem (must precede any log/die calls) ──
    log_init
    _log_enable_err_trap
    log "DEBUG: [INIT] main: mode=$BASHAGT_MODE project_dir=${BASHAGT_PROJECT_DIR:-} session=$BASHAGT_SESSION_ID pid=$$ ppid=$PPID args=$*"

    # ── Uninstall: read PID from settings.json, kill, remove ~/.bashagt ──
    if [[ "$BASHAGT_MODE" == "uninstall" ]]; then
        local _json _pid
        if [[ -f "$HOME/.bashagt/settings.json" ]]; then
            _pid=$(jq -r '.pid // ""' "$HOME/.bashagt/settings.json" 2>/dev/null)
            if [[ -n "$_pid" ]] && kill -0 "$_pid" 2>/dev/null; then
                printf 'Stopping bashagt daemon (PID %s)...\n' "$_pid"
                # Send TERM to trigger _daemon_cleanup (takes ~4s with its 2s sleeps)
                kill "$_pid" 2>/dev/null || true
                sleep 5
                # Belt and suspenders: kill entire process tree
                _pkill_tree "$_pid" KILL 2>/dev/null || true
            fi
        fi
        # Release port if still held
        _port_kill 9655
        rm -f "${TMPDIR:-/tmp}"/bashagt_w.* "${TMPDIR:-/tmp}"/bashagt_r.* 2>/dev/null || true
        # Remove bashagt lines from ~/.bashrc and ~/.zshrc
        if [[ -f "$HOME/.bashrc" ]]; then
            grep -vE '# bashagt( hotkeys)?$' "$HOME/.bashrc" > "$HOME/.bashrc.tmp" 2>/dev/null && \
                mv "$HOME/.bashrc.tmp" "$HOME/.bashrc" 2>/dev/null || true
        fi
        if [[ -f "$HOME/.zshrc" ]]; then
            grep -vE '# bashagt( hotkeys)?$' "$HOME/.zshrc" > "$HOME/.zshrc.tmp" 2>/dev/null && \
                mv "$HOME/.zshrc.tmp" "$HOME/.zshrc" 2>/dev/null || true
        fi
        # Remove symlink from whichever bin dir it was placed in
        local _bin_dir; _bin_dir=$(_find_user_bin 2>/dev/null || echo "$HOME/.local/bin")
        rm -f "$_bin_dir/bashagt" 2>/dev/null || true
        if [[ -d "$HOME/.bashagt" ]]; then
            printf 'Removing %s...\n' "$HOME/.bashagt"
            rm -rf "$HOME/.bashagt"
        fi
        printf 'bashagt uninstalled.\n'
        exit 0
    fi

    # ── Update: kill daemon, copy binary, refresh symlink, regenerate keybindings ──
    if [[ "$BASHAGT_MODE" == "update" ]]; then
        local _pid
        if [[ -f "$HOME/.bashagt/settings.json" ]]; then
            _pid=$(jq -r '.pid // ""' "$HOME/.bashagt/settings.json" 2>/dev/null)
            if [[ -n "$_pid" ]] && kill -0 "$_pid" 2>/dev/null; then
                printf 'Stopping bashagt daemon (PID %s)...\n' "$_pid"
                kill "$_pid" 2>/dev/null || true
                sleep 4
                _pkill_tree "$_pid" KILL 2>/dev/null || true
                _port_kill "${BASHAGT_DAEMON_PORT:-9655}"
            fi
        fi
        mkdir -p "$HOME/.bashagt"
        cp "$0" "$HOME/.bashagt/bashagt" 2>/dev/null || true
        chmod +x "$HOME/.bashagt/bashagt" 2>/dev/null || true
        local _update_self; _update_self=$(realpath "$0" 2>/dev/null || readlink -f "$0" 2>/dev/null || echo "$0")
        local _webui_src; _webui_src="$(dirname "$_update_self")/webui.html"
        [[ -f "$_webui_src" ]] && cp "$_webui_src" "$HOME/.bashagt/webui.html" 2>/dev/null || true
        _install_symlink
        _install_keybindings
        printf 'bashagt updated — hotkeys will auto-reload on next use.\n'
        printf 'To reload now: unset _BSHT_KB_LOADED && source ~/.bashagt/keybindings.sh\n'
        printf '               (zsh: unset _BSHT_KB_LOADED && source ~/.bashagt/keybindings.zsh)\n'
        printf 'To restart daemon: bashagt --run\n'
        exit 0
    fi

    # ── Install: initialize system dirs, agents, symlinks, keybindings ──
    if [[ "$BASHAGT_MODE" == "install" ]]; then
        init_system_dirs
        _install_keybindings
        printf 'bashagt installed.\n'
        printf '1. Edit %s to set your API key, or export BASHAGT_API_KEY\n' "$HOME/.bashagt/settings.json"
        printf '2. Start daemon: bashagt --run\n'
        exit 0
    fi

    init_system_dirs
    init_project_dirs
    trace_init
    load_config
    load_bashagt_md
    if [[ "${BASHAGT_MODE:-interactive}" != "oneshot" ]]; then
        check_deps  # oneshot: deps already validated by parent process
    fi
    load_agents
    load_skills
    load_hooks
    # Built-in pre_turn hook: inject active job summary every turn
    register_hook "pre_turn" 50 "builtin_job_context" "inline_bash" \
        '_hook_job_context'
    # Seed _SKILL_DIR_MTIME baseline so first-turn _reload_skills_if_stale is a no-op
    { local _d _m; for _d in "$HOME/.bashagt/skills/" .bashagt/skills/; do
        [[ -d "$_d" ]] && { _m=$(_file_mtime "$_d"); (( _m > _SKILL_DIR_MTIME )) && _SKILL_DIR_MTIME=$_m; }
    done; } 2>/dev/null || true
    init_task_dir
    load_memories
    load_todos
    load_history
    _pe_cache_init
    _agent_sched_init
    _context_static_init     # build static context prefix once
    _context_rebuild          # build initial dyn_msg cache

    # Init MCP (graceful: failures don't prevent startup)
    if [[ "${BASHAGT_MCP_ENABLED:-true}" == "true" && "$BASHAGT_MODE" != "run" ]]; then
        mcp_init || log "WARN: MCP initialization had errors"
    fi

    # ── Run mode: start daemon (background by default, foreground with --debug) ──
    if [[ "$BASHAGT_MODE" == "run" ]]; then
        local _port="${BASHAGT_DAEMON_PORT:-9655}"
        local _sfile="$HOME/.bashagt/settings.json"
        # Write PID (current process, before any fork)
        if [[ -f "$_sfile" ]]; then
            jq --arg pid "$$" '.pid = ($pid | tonumber)' "$_sfile" > "$_sfile.tmp" && mv "$_sfile.tmp" "$_sfile"
        else
            jq -n --arg pid "$$" '{pid: ($pid | tonumber)}' > "$_sfile"
        fi
        if [[ "${BASHAGT_DEBUG:-0}" == "1" ]]; then
            export BASHAGT_LOG_LEVEL=DEBUG
            LOG_LEVEL_NUM=0
            BASHAGT_LOG_STDERR=1
            printf 'bashagt daemon on port %s (PID %s) [foreground, DEBUG]\n' "$_port" "$$"
            trap '_proc_shutdown; save_history; exit 130' INT
            trap '(( STATUS_ACTIVE )) && printf "\n\033[K" >&2 || true; _persistent_renderer_teardown; _hook_fire "on_cleanup" "{}" >/dev/null 2>&1 || true; _proc_shutdown; save_history' EXIT TERM
            _daemon_main
        else
            # Port pre-check before forking — fail fast if port is taken
            if _port_is_busy "${_port}"; then
                printf '[bashagt] ERROR: port %s already in use\n' "$_port" >&2
                exit 1
            fi
            # Fork daemon, suppress [1] PID job notification
            exec 3>&2 2>/dev/null
            _daemon_main &
            local _dpid=$!
            exec 2>&3 3>&-
            # Update PID to the actual daemon process PID
            jq --arg pid "$_dpid" '.pid = ($pid | tonumber)' "$_sfile" > "$_sfile.tmp" && mv "$_sfile.tmp" "$_sfile"
            disown $_dpid
            sleep 0.3
            if ! kill -0 "$_dpid" 2>/dev/null; then
                printf '[bashagt] ERROR: daemon failed to start\n' >&2
                exit 1
            fi
            printf 'bashagt daemon started on port %s (PID %s)\n' "$_port" "$_dpid"
            exit 0
        fi
    fi

    trap '_proc_shutdown; save_history; exit 130' INT
    trap '(( STATUS_ACTIVE )) && printf "\n\033[K" >&2 || true; _persistent_renderer_teardown; _hook_fire "on_cleanup" "{}" >/dev/null 2>&1 || true; _proc_shutdown; save_history' EXIT TERM

    # ── post_init hook — startup injection ──
    local _pi_ctx _pi_results _pi_item
    _pi_ctx=$(jq -nc \
        --arg dir "${BASHAGT_PROJECT_DIR:-.}" \
        --arg model "${BASHAGT_MODEL:-$DEFAULT_MODEL}" \
        --argjson agents "$(jq -s 'map(.name)' <<< "$AGENT_DESCRIPTIONS" 2>/dev/null || echo 0)" \
        --argjson skills "${#SKILLS[@]}" \
        --argjson mcp "${#MCP_SERVERS[@]}" \
        '{project:{dir:$dir},system:{model:$model,agents:$agents,skills:$skills,mcp_servers:$mcp}}')
    _pi_results=$(_hook_fire "post_init" "$_pi_ctx")
    if [[ "$_pi_results" != "[]" && -n "$_pi_results" ]]; then
        while IFS= read -r _pi_item; do
            [[ -z "$_pi_item" ]] && continue
            local _inj; _inj=$(jq -r '.inject // false' <<< "$_pi_item" 2>/dev/null)
            if [[ "$_inj" == "true" ]]; then
                local _pic; _pic=$(jq -r '.content // ""' <<< "$_pi_item" 2>/dev/null)
                [[ -n "$_pic" ]] && msg_add_user_text "$_pic"
            fi
        done < <(jq -c '.[]' <<< "$_pi_results" 2>/dev/null)
    fi

    _tm "main_init_done"
    agent_loop "$@"
}

# Test-mode guard: when sourced with BASHAGT_TEST_MODE set, skip main()
# so test files can load all functions without triggering init/runtime logic.
[[ -n "${BASHAGT_TEST_MODE:-}" ]] && return 0
main "$@"