#!/usr/bin/env Rscript
#
# corteza - An open-source interactive CLI agent in R
#
# Usage: corteza [options]
#   --provider   anthropic|openai|moonshot|ollama (default: anthropic)
#   --model      model name (default: provider-specific)
#   --port       MCP server port (default: 7850)
#   --session    Session key (resume if exists, create if not)
#   --list       List sessions
#

# Load .Renviron explicitly (littler may not load it automatically)
renviron <- path.expand("~/.Renviron")
if (file.exists(renviron)) readRenviron(renviron)

suppressPackageStartupMessages({
  library(jsonlite)
  library(llm.api)
  library(corteza)
})

# ============================================================================
# CLI argument parsing
# ============================================================================

parse_args <- function() {
  args <- commandArgs(trailingOnly = TRUE)

  # Load config for defaults
  config <- corteza:::load_config(getwd())

  opts <- list(
    provider = config$provider %||% "anthropic",
    model = config$model,
    port = config$port %||% 7850L,
    tools = config$tools,  # NULL = all tools
    context_warn_pct = config$context_warn_pct %||% 75L,
    context_high_pct = config$context_high_pct %||% 90L,
    context_crit_pct = config$context_crit_pct %||% 95L,
    context_compact_pct = config$context_compact_pct %||% 90L,  # Auto-compact threshold
    session = NULL,  # Session key (resume if exists, create if not)
    resume = FALSE,  # Resume latest session
    list = FALSE,
    dry_run = config$dry_run %||% FALSE,  # Dry-run mode
    trace = isTRUE(config$trace) || isTRUE(getOption("corteza.trace", FALSE))
  )

  i <- 1
  while (i <= length(args)) {
    arg <- args[i]
    if (arg == "--provider" && i < length(args)) {
      opts$provider <- args[i + 1]
      i <- i + 2
    } else if (arg == "--model" && i < length(args)) {
      opts$model <- args[i + 1]
      i <- i + 2
    } else if (arg == "--port" && i < length(args)) {
      opts$port <- as.integer(args[i + 1])
      i <- i + 2
    } else if ((arg == "--session" || arg == "-s") && i < length(args)) {
      opts$session <- args[i + 1]
      i <- i + 2
    } else if (arg == "--resume" || arg == "-r") {
      opts$resume <- TRUE
      i <- i + 1
    } else if (arg == "--list") {
      opts$list <- TRUE
      i <- i + 1
    } else if (arg == "--tools" && i < length(args)) {
      opts$tools <- strsplit(args[i + 1], ",")[[1]]
      i <- i + 2
    } else if (arg == "--dry-run") {
      opts$dry_run <- TRUE
      i <- i + 1
    } else if (arg == "--trace") {
      opts$trace <- TRUE
      i <- i + 1
    } else if (arg == "--help" || arg == "-h") {
      cat("
corteza - An open-source interactive CLI agent

Usage: corteza [options]

Options:
  --provider      LLM provider: anthropic, openai, moonshot, ollama (default: anthropic)
  --model         Model name (default: auto per provider)
  --port          MCP server port (default: 7850)
  --tools         Tool filter: core, file, code, git, r, data, web, chat (default: all)
                  Example: --tools core or --tools file,git,code
  --dry-run       Preview tool calls without executing
  --session, -s   Session key (resume if exists, create if not)
  --resume, -r    Resume the most recent session
  --list          List sessions
  --help, -h      Show this help

Commands (in chat):
  /quit, /exit   Exit corteza
  /status        Show current runtime/session status
  /doctor        Check provider, git, MCP, and context health
  /tools         List available tools
  /diff [ref]    Show git diff against HEAD or a ref
  /review [ref]  Review local changes with the current model
  /config        Show active runtime configuration
  /permissions   Show tool approval and sandbox settings
  /clear         Clear conversation (keeps session)
  /compact       Summarize conversation to free context
  /sessions      List sessions
  /context       Show live context usage and loaded context files
  /model <name>  Switch model
  /provider <p>  Switch provider
  /remember      Store fact with #tags (auto-categorized)
  /recall        Search memories by keyword or tag

Project context is loaded from saber::briefing() and saber::agent_context().
Additional files can be loaded via the `context_files` config key.

Legacy corteza memory injection is disabled by default.
Use config to opt back into MEMORY.md or daily memory logs.

Config files:
  tools::R_user_dir('corteza', 'config')/config.json   Global defaults
  .corteza/config.json                                 Project overrides

")
      quit(save = "no", status = 0)
    } else {
      i <- i + 1
    }
  }

  opts
}

# Read a single line of user input. On Unix we shell out to bash's
# `read -e` so arrow keys and history work in Rscript contexts where
# R's own readline() is gimped. On Windows the bash hack breaks under
# PowerShell / cmd (quote mangling, no TTY through system2), so we
# fall back to readLines(stdin). Line editing on Windows is handled
# by the terminal itself.
cli_read_line <- function(prompt_str = "> ") {
  corteza:::read_prompt_input(prompt_str, use_readline = FALSE)
}

# ============================================================================
# CLI worker transport
#
# The CLI drives a private R subprocess for tool execution via
# callr::r_session. Tool dispatch goes directly through
# corteza:::worker_dispatch() in the session; no JSON-RPC, no MCP.
#
# serve() remains a spec-compliant MCP server for external clients
# (Claude Desktop, VS Code); nothing in that path changes.
#
# The `port` argument on cli_worker_start / cli_worker_connect is
# vestigial from the MCP era and kept only so the CLI's outer control
# flow doesn't need rewriting in this phase. It's ignored.
# ============================================================================

cli_worker_start <- function(port, tools = NULL) {
  # callr::r_session starts on-demand inside cli_worker_connect; there
  # is no separate long-lived server to probe or spawn here. Kept so
  # the CLI control flow reads the same as before the transport swap.
  TRUE
}

cli_worker_connect <- function(port, cwd = getwd(), tools_filter = NULL) {
    corteza::cli_worker_spawn(cwd = cwd)
}

# Normalize an error crossing the callr boundary into the MCP-shaped
# result the rest of the CLI expects. callr wraps caller conditions as
# callr::callr_error with the original condition attached at $parent.
.cli_worker_error_result <- function(e) {
    cause <- e$parent %||% e
    if (inherits(cause, "corteza_tool_error")) {
        list(
            isError = TRUE,
            content = list(list(type = "text",
                                text = sprintf("Error in %s: %s",
                                               cause$tool,
                                               conditionMessage(cause))))
        )
    } else {
        list(
            isError = TRUE,
            content = list(list(type = "text",
                                text = paste("Error:", conditionMessage(cause))))
        )
    }
}

cli_worker_call <- function(worker, name, args) {
    result <- tryCatch(
        worker$session$run(
            function(name, args) corteza::worker_dispatch(name, args),
            list(name = name, args = args)
        ),
        error = .cli_worker_error_result
    )
    # Drain any structured events the worker emitted to stderr while
    # the tool ran. When --trace is on they are pretty-printed;
    # otherwise we read and discard so the stderr buffer doesn't
    # accumulate.
    corteza::cli_worker_drain_events(
        worker$session,
        trace = isTRUE(getOption("corteza.trace", FALSE))
    )
    # Match the $text convenience mcp_call used to provide.
    if (!is.null(result$content) && is.null(result$text)) {
        texts <- vapply(result$content, function(c) {
            if (identical(c$type, "text")) c$text else ""
        }, character(1))
        result$text <- paste(texts, collapse = "\n")
    }
    result
}

# (Event drain and pretty-print moved to corteza::cli_worker_drain_events
# so printify is imported from R/ code, not only from this shell script.)

cli_worker_tools_for_api <- function(worker, filter = NULL) {
    # Schemas are built in the CLI process from its own tool registry —
    # the callr worker doesn't participate in schema generation. The
    # worker arg is accepted for call-site compatibility and ignored.
    corteza::ensure_skills()
    corteza::schema_from_registry(filter)
}

cli_worker_close <- function(worker) {
    if (!is.null(worker$session)) {
        try(worker$session$close(), silent = TRUE)
    }
    invisible(NULL)
}

# ============================================================================
# Display helpers
# ============================================================================

# Detect whether the terminal interprets ANSI escape sequences. Modern
# Linux/macOS terminals and Windows Terminal / ConEmu / VS Code's
# integrated terminal do; the classic powershell.exe and cmd.exe
# consoles do not unless VT mode is explicitly enabled. Emitting
# escapes into a non-VT console leaves "[32m" garbage on screen, so
# we default OFF for classic Windows consoles and let NO_COLOR /
# FORCE_COLOR override either way.
.ansi_supported <- function() {
  if (nzchar(Sys.getenv("NO_COLOR"))) return(FALSE)
  if (nzchar(Sys.getenv("FORCE_COLOR"))) return(TRUE)
  if (.Platform$OS.type != "windows") return(TRUE)
  # Windows Terminal, ConEmu, VS Code, and anything that advertises a
  # real terminal type all set one of these.
  any(nzchar(Sys.getenv(c("WT_SESSION", "ConEmuANSI", "TERM_PROGRAM"))))
}

.ansi <- .ansi_supported()

color <- if (.ansi) {
  list(
    reset = "\033[0m",
    bold = "\033[1m",
    dim = "\033[2m",
    red = "\033[31m",
    green = "\033[32m",
    yellow = "\033[33m",
    blue = "\033[34m",
    magenta = "\033[35m",
    cyan = "\033[36m",
    white = "\033[37m",
    bright_red = "\033[91m",
    bright_green = "\033[92m",
    bright_yellow = "\033[93m",
    bright_blue = "\033[94m",
    bright_magenta = "\033[95m",
    bright_cyan = "\033[96m"
  )
} else {
  # No-op escapes for terminals that don't interpret VT sequences.
  stats::setNames(as.list(rep("", 16L)),
                  c("reset", "bold", "dim",
                    "red", "green", "yellow", "blue", "magenta", "cyan", "white",
                    "bright_red", "bright_green", "bright_yellow",
                    "bright_blue", "bright_magenta", "bright_cyan"))
}

print_banner <- function(session_id = NULL) {
  cat(sprintf("\n%s corteza%s", color$cyan, color$reset))
  if (!is.null(session_id)) {
    cat(sprintf(" %s[%s]%s", color$dim, session_id, color$reset))
  }
  cat("\n")
  cat(sprintf("%sType /help for commands, /quit to exit%s\n\n", color$dim, color$reset))
}

print_tool <- function(name, args) {
  # Function-style: tool_name("arg1", "arg2")
  if (length(args) > 0) {
    args_str <- paste(sapply(args, function(x) {
      if (is.character(x)) {
        s <- if (nchar(x) > 40) paste0(substr(x, 1, 37), "...") else x
        sprintf('"%s"', s)
      } else {
        as.character(x)
      }
    }), collapse = ", ")
    cat(sprintf("%s%s(%s)%s\n", color$dim, name, args_str, color$reset))
  } else {
    cat(sprintf("%s%s()%s\n", color$dim, name, color$reset))
  }
}

print_result <- function(text) {
  # Minimal output - just show line count, model will summarize
  lines <- strsplit(text, "\n")[[1]]
  n <- length(lines)
  if (n > 1) {
    cat(sprintf("%s(%d lines)%s\n", color$dim, n, color$reset))
  }
  # Single line results shown inline with tool call
}

render_markdown <- function(text) {
  # Bold: **text** or __text__ - bright white
  text <- gsub("\\*\\*([^*]+)\\*\\*", paste0(color$bold, color$white, "\\1", color$reset), text)
  text <- gsub("__([^_]+)__", paste0(color$bold, color$white, "\\1", color$reset), text)

  # Italic: *text* or _text_ (but not inside words)
  text <- gsub("(?<![\\w*])\\*([^*]+)\\*(?![\\w*])", paste0(color$dim, "\\1", color$reset), text, perl = TRUE)
  text <- gsub("(?<![\\w_])_([^_]+)_(?![\\w_])", paste0(color$dim, "\\1", color$reset), text, perl = TRUE)

  # Inline code: `code` - cyan
  text <- gsub("`([^`]+)`", paste0(color$bright_cyan, "\\1", color$reset), text)

  # Headers - colorful
  text <- gsub("(?m)^### (.+)$", paste0(color$bright_blue, "\\1", color$reset), text, perl = TRUE)
  text <- gsub("(?m)^## (.+)$", paste0(color$bold, color$bright_blue, "\\1", color$reset), text, perl = TRUE)
  text <- gsub("(?m)^# (.+)$", paste0(color$bold, color$bright_magenta, "\\1", color$reset), text, perl = TRUE)

  # Bullet points - green bullet
  text <- gsub("(?m)^(\\s*)[-*] ", paste0("\\1", color$green, "•", color$reset, " "), text, perl = TRUE)

  # Links: [text](url) - blue text, dim url
  text <- gsub("\\[([^]]+)\\]\\(([^)]+)\\)", paste0(color$bright_blue, "\\1", color$reset, " ", color$dim, "(\\2)", color$reset), text)

  text
}

print_response <- function(text) {
  rendered <- render_markdown(text)
  cat(sprintf("\n%s%s\n", rendered, color$reset))
}

# ============================================================================
# Context tracking
# ============================================================================

# Model context limits (in tokens)
context_limits <- list(
  # Anthropic
  "claude-sonnet-4-20250514" = 200000L,
  "claude-opus-4-20250514" = 200000L,
  "claude-3-5-sonnet-20241022" = 200000L,
  "claude-3-opus-20240229" = 200000L,
  "claude-3-haiku-20240307" = 200000L,
  # OpenAI
  "gpt-4o" = 128000L,
  "gpt-4o-mini" = 128000L,
  "gpt-4-turbo" = 128000L,
  "gpt-4" = 8192L,
  "gpt-3.5-turbo" = 16385L,
  # Ollama (varies by model)
  "llama3.2" = 128000L,
  "llama3.1" = 128000L,
  "mistral" = 32000L,
  "mixtral" = 32000L,
  "qwen2.5" = 32000L
)

get_context_limit <- function(model) {
  # Try exact match first
  if (!is.null(context_limits[[model]])) {
    return(context_limits[[model]])
  }
  # Try prefix match
  for (name in names(context_limits)) {
    if (startsWith(model, name) || startsWith(name, model)) {
      return(context_limits[[name]])
    }
  }
  # Default
  128000L
}

format_tokens <- function(n) {
  if (n >= 1000000) {
    sprintf("%.1fM", n / 1000000)
  } else if (n >= 1000) {
    sprintf("%.1fK", n / 1000)
  } else {
    as.character(n)
  }
}

estimate_text_tokens <- function(text) {
  if (is.null(text) || length(text) == 0L) {
    return(0L)
  }
  text <- paste(as.character(text), collapse = "\n")
  if (!nzchar(text)) {
    return(0L)
  }
  as.integer(ceiling(nchar(text, type = "chars", allowNA = FALSE) / 4))
}

message_text <- function(message) {
  content <- message$content
  if (is.list(content)) {
    if (length(content) > 0L && !is.null(content[[1]]$text)) {
      return(paste(vapply(
        content,
        function(block) as.character(block$text %||% ""),
        character(1)
      ), collapse = "\n"))
    }
    return(paste(utils::capture.output(str(content, max.level = 2L)),
                 collapse = "\n"))
  }
  as.character(content %||% "")
}

estimate_live_context_tokens <- function(session, system_prompt = NULL,
                                         tools = NULL) {
  tool_text <- tryCatch(
    jsonlite::toJSON(tools %||% list(), auto_unbox = TRUE, null = "null"),
    error = function(e) ""
  )
  messages <- session$messages %||% list()
  message_texts <- vapply(messages, function(message) {
    sprintf("%s: %s", message$role %||% "unknown", message_text(message))
  }, character(1))

  parts <- c(system_prompt %||% "", tool_text, message_texts)
  tokens <- sum(vapply(parts, estimate_text_tokens, integer(1)))

  # Account for chat framing overhead that the chars/4 estimate misses.
  tokens + length(messages) * 6L + length(tools %||% list()) * 12L
}

default_provider_model <- function(provider) {
  switch(provider,
    anthropic = "claude-sonnet-4-20250514",
    openai = "gpt-4o",
    moonshot = "kimi-k2.6",
    ollama = "llama3.2",
    NULL
  )
}

resolve_provider_model <- function(provider, model = NULL) {
  if (!is.null(model) && nzchar(model)) {
    if (identical(provider, "moonshot") && identical(model, "kimi-k2")) {
      return("kimi-k2.6")
    }
    return(model)
  }
  default_provider_model(provider)
}

preferred_chat_temperature <- function(provider, temperature) {
  if (identical(provider, "moonshot")) {
    return(1)
  }
  temperature
}

print_context_indicator <- function(used, limit, warn_pct = 75, high_pct = 90, crit_pct = 95) {
  pct <- (used / limit) * 100

  # Don't show until we hit warning threshold
  if (pct < warn_pct) return(invisible(NULL))

  used_str <- format_tokens(used)
  limit_str <- format_tokens(limit)

  # Color based on usage: yellow -> orange -> red
  if (pct >= crit_pct) {
    col <- color$bright_red
    warn <- " (consider /clear)"
  } else if (pct >= high_pct) {
    col <- "\033[38;5;208m"  # Orange (256-color)
    warn <- ""
  } else {
    col <- color$yellow
    warn <- ""
  }

  cat(sprintf("%s[%s / %s tokens %.0f%%]%s%s\n",
              col, used_str, limit_str, pct, warn, color$reset))
}

# ============================================================================
# Auto-compaction
# ============================================================================

compact_prompt <- '
Summarize this conversation concisely, preserving:
1. What was accomplished (completed tasks, files modified)
2. Current work in progress
3. Key decisions and constraints mentioned
4. Pending tasks or next steps
5. Any errors encountered and their resolution

Be specific about file names, function names, and technical details.
Format as a structured summary the assistant can use to continue the work.
'

do_compact <- function(session, provider, model, system_prompt) {
  cat(sprintf("%s\nAuto-compacting conversation...%s\n", color$cyan, color$reset))

  # Build conversation text from messages (extract text from content blocks)
  conv_text <- vapply(session$messages, function(m) {
    text <- if (is.list(m$content) && length(m$content) > 0 && !is.null(m$content[[1]]$text)) {
      m$content[[1]]$text
    } else {
      m$content
    }
    sprintf("[%s]: %s", m$role, text)
  }, character(1))
  conv_text <- paste(conv_text, collapse = "\n\n")

  # Ask LLM to summarize
  summary_prompt <- sprintf("%s\n\n---\nConversation to summarize:\n%s", compact_prompt, conv_text)

  result <- tryCatch({
    llm.api::chat(
      prompt = summary_prompt,
      provider = provider,
      model = resolve_provider_model(provider, model),
      system = "You are a helpful assistant that creates concise conversation summaries.",
      temperature = preferred_chat_temperature(provider, 0.3)
    )
  }, error = function(e) {
    cat(sprintf("%sCompaction failed: %s%s\n", color$bright_magenta, e$message, color$reset))
    NULL
  })

  if (is.null(result)) {
    return(NULL)
  }

  summary <- result$content

  # Estimate tokens in summary (~4 chars per token)
  new_tokens <- as.integer(nchar(summary) / 4)

  cat(sprintf("%sCompacted to ~%s tokens%s\n", color$green, format_tokens(new_tokens), color$reset))

  list(summary = summary, tokens = new_tokens)
}


# ============================================================================
# Tool output buffer (for /last command)
# ============================================================================

# Store last N tool outputs
.tool_buffer <- new.env(parent = emptyenv())
.tool_buffer$outputs <- list()
.tool_buffer$max_size <- 20L

tool_buffer_add <- function(name, args, result) {
  entry <- list(
    name = name,
    args = args,
    result = result,
    time = Sys.time()
  )
  .tool_buffer$outputs <- c(list(entry), .tool_buffer$outputs)
  # Trim to max size
  if (length(.tool_buffer$outputs) > .tool_buffer$max_size) {
    .tool_buffer$outputs <- .tool_buffer$outputs[1:.tool_buffer$max_size]
  }
}

tool_buffer_get <- function(n = 1) {
  if (n > length(.tool_buffer$outputs)) {
    return(NULL)
  }
  .tool_buffer$outputs[[n]]
}

tool_buffer_list <- function() {
  .tool_buffer$outputs
}

# ============================================================================
# Tool approval system
# ============================================================================

# Check if a tool requires approval
requires_approval <- function(tool_name, dangerous_tools) {
  tool_name %in% dangerous_tools
}

# Load approvals from project-local file
load_approvals <- function(cwd) {
  path <- file.path(cwd, ".corteza", "approvals.json")
  if (file.exists(path)) {
    tryCatch(
      jsonlite::fromJSON(path, simplifyVector = FALSE),
      error = function(e) list()
    )
  } else {
    list()
  }
}

# Save approval to project-local file
save_approval <- function(cwd, tool_name) {
  approvals <- load_approvals(cwd)
  approvals[[tool_name]] <- TRUE

  path <- file.path(cwd, ".corteza", "approvals.json")
  dir.create(dirname(path), showWarnings = FALSE, recursive = TRUE)
  jsonlite::write_json(approvals, path, auto_unbox = TRUE, pretty = TRUE)
}

# Check if tool is already approved for this project
is_approved <- function(cwd, tool_name) {
  approvals <- load_approvals(cwd)
  isTRUE(approvals[[tool_name]])
}

# Ask user for approval
ask_approval <- function(name, args, cwd) {
  call <- list(tool = name, args = as.list(args), channel = "cli")
  call$paths <- corteza:::resolve_paths(call)
  call$urls <- corteza:::resolve_urls(call)
  decision <- tryCatch(corteza::policy(call), error = function(e) NULL)

  lines <- corteza:::cli_approval_lines(
    call,
    decision,
    gate_reason = sprintf("Project config requires approval for %s.", name),
    cwd = cwd,
    persistent_label = "Allow always for this project"
  )
  cat(paste(lines, collapse = "\n"), "\n")

  response <- cli_read_line(sprintf("%sChoice [1]: %s",
                                    color$yellow, color$reset))
  if (length(response) == 0) response <- ""
  response <- trimws(response)

  # Default to "1" (allow once) if empty
  if (response == "") response <- "1"

  list(
    approved = response %in% c("1", "2"),
    always = response == "2"
  )
}

# ============================================================================
# CLI status helpers
# ============================================================================

capture_command <- function(command, args = character()) {
  output <- tryCatch(
    system2(command, args, stdout = TRUE, stderr = TRUE),
    error = function(e) structure(paste("Error:", e$message), status = 1L)
  )

  list(
    lines = output,
    text = paste(output, collapse = "\n"),
    status = attr(output, "status") %||% 0L
  )
}

truncate_output <- function(text, max_lines = 300L, max_chars = 60000L) {
  if (is.null(text) || nchar(text) == 0) {
    return("")
  }

  lines <- strsplit(text, "\n", fixed = TRUE)[[1]]
  if (length(lines) > max_lines) {
    lines <- c(lines[seq_len(max_lines)],
               sprintf("[truncated at %d lines]", max_lines))
  }
  text <- paste(lines, collapse = "\n")

  if (nchar(text) > max_chars) {
    text <- paste0(substr(text, 1, max_chars), "\n[truncated by character limit]")
  }

  text
}

provider_env_var <- function(provider) {
  switch(provider,
         anthropic = "ANTHROPIC_API_KEY",
         openai = "OPENAI_API_KEY",
         paste0(toupper(provider), "_API_KEY"))
}

provider_status <- function(provider, model = NULL) {
  if (provider == "ollama") {
    err <- tryCatch({
      validate_model(provider, model)
      NULL
    }, error = function(e) e$message)

    if (is.null(err)) {
      return(list(ok = TRUE, message = "ollama reachable"))
    }
    return(list(ok = FALSE, message = err))
  }

  env_var <- provider_env_var(provider)
  if (nchar(Sys.getenv(env_var, "")) == 0) {
    return(list(ok = FALSE, message = paste("missing", env_var)))
  }

  list(ok = TRUE, message = paste(env_var, "set"))
}

in_git_repo <- function() {
  result <- capture_command("git", c("rev-parse", "--is-inside-work-tree"))
  result$status == 0L && identical(trimws(result$text), "true")
}

collect_git_diff <- function(ref = NULL) {
  if (!in_git_repo()) {
    return(list(ok = FALSE, text = "Not inside a git repository."))
  }

  target <- trimws(ref %||% "")
  target <- if (nchar(target) > 0) target else "HEAD"

  status <- capture_command("git", c("status", "--short"))
  diff <- capture_command(
    "git",
    c("diff", "--no-ext-diff", "--find-renames", "--unified=3", target)
  )

  if (diff$status != 0L) {
    return(list(ok = FALSE, text = diff$text))
  }

  if (nchar(trimws(diff$text)) == 0) {
    if (nchar(trimws(status$text)) > 0) {
      return(list(
        ok = FALSE,
        text = paste0(
          "No tracked diff against ", target,
          ". Untracked or ignored files may still be present.\n\n",
          truncate_output(status$text, max_lines = 50L, max_chars = 4000L)
        )
      ))
    }
    return(list(ok = FALSE, text = paste("No git diff against", target, ".")))
  }

  list(
    ok = TRUE,
    target = target,
    status = truncate_output(status$text, max_lines = 80L, max_chars = 6000L),
    diff = truncate_output(diff$text, max_lines = 500L, max_chars = 70000L)
  )
}

format_status_summary <- function(session, provider, display_model, tools, opts,
                                  config, session_tokens, context_limit,
                                  context_files, skill_docs) {
  memory_mode <- if (isTRUE(config$context_include_memory_logs) ||
                     isTRUE(config$memory_flush_enabled)) {
    "legacy corteza memory enabled"
  } else {
    "legacy corteza memory disabled"
  }

  paste(
    c(
      sprintf("Session: %s", session$sessionKey),
      sprintf("Model: %s @ %s", display_model, provider),
      sprintf("Tools: %d | Dry-run: %s",
              length(tools),
              if (isTRUE(opts$dry_run)) "on" else "off"),
      sprintf("Context: %d project file(s) | %d skill doc(s) | %s",
              length(context_files), length(skill_docs), memory_mode),
      sprintf("Legacy memory tools: %s",
              if (isTRUE(config$legacy_memory_tools_enabled)) "visible" else "hidden"),
      sprintf("Live context: %s / %s tokens",
              format_tokens(session_tokens), format_tokens(context_limit)),
      sprintf("Approval mode: %s", config$approval_mode %||% "ask")
    ),
    collapse = "\n"
  )
}

format_config_summary <- function(config, provider, display_model, opts) {
  paste(
    c(
      "Runtime config",
      sprintf("provider: %s", provider),
      sprintf("model: %s", display_model),
      sprintf("port: %d", opts$port),
      sprintf("tools: %s",
              if (is.null(opts$tools)) "all" else paste(opts$tools, collapse = ", ")),
      sprintf("context files: %s",
              if (length(config$context_files) > 0) {
                paste(config$context_files, collapse = ", ")
              } else {
                "(none)"
              }),
      sprintf("daily memory logs: %s",
              if (isTRUE(config$context_include_memory_logs)) "enabled" else "disabled"),
      sprintf("compaction memory flush: %s",
              if (isTRUE(config$memory_flush_enabled)) "enabled" else "disabled"),
      sprintf("legacy memory tools: %s",
              if (isTRUE(config$legacy_memory_tools_enabled)) "visible" else "hidden"),
      sprintf("approval mode: %s", config$approval_mode %||% "ask"),
      sprintf("dangerous tools: %s",
              paste(config$dangerous_tools %||%
                    corteza:::default_dangerous_tools(),
                    collapse = ", "))
    ),
    collapse = "\n"
  )
}

format_doctor_report <- function(cwd, session, provider, display_model, tools,
                                 config, context_files, skill_docs) {
  provider_check <- provider_status(provider, model = if (!is.null(session$model)) session$model else NULL)
  approvals_path <- file.path(cwd, ".corteza", "approvals.json")

  paste(
    c(
      "corteza doctor",
      sprintf("cwd: %s", cwd),
      sprintf("session: %s", session$sessionKey),
      sprintf("provider: %s (%s)",
              provider,
              if (isTRUE(provider_check$ok)) provider_check$message else paste("check failed:", provider_check$message)),
      sprintf("model: %s", display_model),
      sprintf("worker: connected (%d tools)", length(tools)),
      sprintf("git: %s", if (in_git_repo()) "repository detected" else "not a git repository"),
      sprintf("context files: %d", length(context_files)),
      sprintf("skill docs: %d", length(skill_docs)),
      sprintf("daily memory logs: %s",
              if (isTRUE(config$context_include_memory_logs)) "enabled" else "disabled"),
      sprintf("compaction memory flush: %s",
              if (isTRUE(config$memory_flush_enabled)) "enabled" else "disabled"),
      sprintf("legacy memory tools: %s",
              if (isTRUE(config$legacy_memory_tools_enabled)) "visible" else "hidden"),
      sprintf("approval mode: %s", config$approval_mode %||% "ask"),
      sprintf("project approvals: %s",
              if (file.exists(approvals_path)) approvals_path else "none")
    ),
    collapse = "\n"
  )
}

run_review <- function(provider, model, diff_target, diff_status, diff_text) {
  review_prompt <- paste(
    "Review the current git changes.",
    "Focus on bugs, behavioral regressions, risky assumptions, and missing tests.",
    "List concrete findings first with file paths and line references when the diff makes them available.",
    "If there are no material issues, reply with exactly: No findings.",
    "",
    sprintf("Git diff target: %s", diff_target),
    "",
    "Git status:",
    diff_status %||% "(clean)",
    "",
    "Diff:",
    diff_text,
    sep = "\n"
  )

  tryCatch({
    llm.api::chat(
      prompt = review_prompt,
      provider = provider,
      model = resolve_provider_model(provider, model),
      system = paste(
        "You are performing code review on local changes.",
        "Findings must come first and should prioritize correctness over style."
      ),
      temperature = preferred_chat_temperature(provider, 0.1)
    )
  }, error = function(e) e)
}

# ============================================================================
# Main agent loop
# ============================================================================

run_agent <- function(opts) {
  cwd <- getwd()
  config <- corteza:::load_config(cwd)

  # Handle --list (exit early)
  if (isTRUE(opts$list)) {
    sessions <- corteza:::session_list()
    cat(corteza:::format_session_list(sessions), "\n")
    return(invisible(NULL))
  }

  # Handle --resume: find latest session key
  if (isTRUE(opts$resume) && is.null(opts$session)) {
    latest <- corteza:::session_latest()
    if (!is.null(latest)) {
      opts$session <- latest$sessionKey
      cat(sprintf("%sResuming latest session: %s%s\n", color$dim, latest$sessionKey, color$reset))
    } else {
      cat(sprintf("%sNo sessions to resume. Starting fresh.%s\n", color$dim, color$reset))
    }
  }

  # Load or create session
  session <- NULL
  ws_enabled <- isTRUE(config$workspace$enabled %||% TRUE)

  if (!is.null(opts$session)) {
    # Try to resume session by key, create if not found
    session <- corteza:::session_load(opts$session)
    if (is.null(session)) {
      # Create new session with specified key
      cat(sprintf("%sCreating session: %s%s\n", color$dim, opts$session, color$reset))
      session <- corteza:::session_new(opts$provider, opts$model, cwd, session_key = opts$session)
    } else {
      # Resuming existing session
      cat(sprintf("%sResuming session: %s%s\n", color$dim, opts$session, color$reset))
      # Use session's provider/model unless overridden
      if (is.null(opts$model)) opts$model <- session$model
      opts$provider <- session$provider %||% opts$provider
      # Restore workspace
      if (ws_enabled) {
        loaded <- corteza:::ws_load(session$sessionId)
        if (loaded) {
          cat(sprintf("%sWorkspace restored.%s\n", color$dim, color$reset))
        }
      }
    }
  } else {
    # No session key specified - create new session
    session <- corteza:::session_new(opts$provider, opts$model, cwd)
    # Scan globalenv for existing objects
    if (ws_enabled && isTRUE(config$workspace$scan_globalenv %||% TRUE)) {
      registered <- corteza:::ws_scan_globalenv(
        max_bytes = config$workspace$scan_max_bytes %||% 50e6)
      if (length(registered) > 0) {
        cat(sprintf("%sWorkspace: registered %d objects%s\n",
                    color$dim, length(registered), color$reset))
      }
    }
  }

  print_banner(session$sessionKey)

  # Propagate --trace to the option cli_worker_call reads.
  if (isTRUE(opts$trace)) options(corteza.trace = TRUE)

  # Start the CLI worker (callr::r_session) and initialize corteza
  # inside it.
  cat(sprintf("%sStarting worker...%s\n", color$dim, color$reset))

  if (!cli_worker_start(opts$port, opts$tools)) {
    cat(sprintf("%sError: Could not start worker%s\n", color$bright_magenta, color$reset))
    cat("Make sure corteza package is installed.\n")
    return(invisible(NULL))
  }

  worker <- tryCatch(
    cli_worker_connect(opts$port, cwd = cwd, tools_filter = opts$tools),
    error = function(e) {
      cat(sprintf("%sError connecting to worker: %s%s\n", color$bright_magenta, e$message, color$reset))
      NULL
    }
  )

  if (is.null(worker)) return(invisible(NULL))

  # Register built-in skills in the CLI process and build the LLM
  # tools payload locally — no worker round-trip.
  corteza::ensure_skills()
  tools <- cli_worker_tools_for_api(worker, filter = opts$tools)

  cat(sprintf("%sConnected. %d tools available.%s\n\n",
              color$dim, length(tools), color$reset))

  # Load skill docs (SKILL.md files)
  corteza:::load_skill_docs(corteza:::corteza_data_path("skills"))
  corteza:::load_skill_docs(file.path(cwd, ".corteza", "skills"))
  skill_docs <- corteza:::list_skill_docs()

  # Load context files
  context_files <- corteza:::list_context_files(cwd)
  system_prompt <- corteza:::load_context(cwd)

  if (length(context_files) > 0 || length(skill_docs) > 0) {
    cat(sprintf("%sLoaded context:%s\n", color$dim, color$reset))
    for (f in context_files) {
      cat(sprintf("  %s%s%s\n", color$cyan, basename(f), color$reset))
    }
    if (length(skill_docs) > 0) {
      cat(sprintf("  %s%d skill(s): %s%s\n", color$cyan, length(skill_docs),
                  paste(skill_docs, collapse = ", "), color$reset))
    }
    cat("\n")
  }

  # `tools` already built above, right after the worker connected.

  # Get approval settings from config
  approval_mode <- config$approval_mode %||% "ask"
  dangerous_tools <- config$dangerous_tools %||% corteza:::default_dangerous_tools()

  # Track turn number for trace
  turn_number <- 0L

  # Tool handler (captures cwd, approval_mode, dangerous_tools, opts in closure)
  tool_handler <- function(name, args) {
    # Check dry-run mode first
    if (isTRUE(opts$dry_run)) {
      cat(sprintf("%s[DRY RUN] %s%s\n", color$yellow, name, color$reset))
      # Call skill directly with dry_run=TRUE (bypass MCP server)
      skill <- corteza:::get_skill(name)
      if (!is.null(skill)) {
        preview <- corteza:::format_dry_run_preview(skill, args)
        cat(sprintf("%s%s%s\n", color$dim, preview, color$reset))
        return(preview)
      }
      return(sprintf("[DRY RUN] Would call: %s", name))
    }

    # Check if approval needed (only in "ask" mode)
    approved_by <- "auto"
    if (approval_mode == "ask" && requires_approval(name, dangerous_tools)) {
      if (!is_approved(cwd, name)) {
        approval <- ask_approval(name, args, cwd)
        if (!approval$approved) {
          cat(sprintf("%sDenied.%s\n", color$dim, color$reset))
          return("Tool execution denied by user")
        }
        if (approval$always) {
          save_approval(cwd, name)
          cat(sprintf("%sApproved for this project%s\n", color$dim, color$reset))
          approved_by <- "always"
        } else {
          approved_by <- "user"
        }
      } else {
        approved_by <- "config"
      }
    } else if (approval_mode == "deny" && requires_approval(name, dangerous_tools)) {
      cat(sprintf("%sDenied (approval_mode=deny).%s\n", color$dim, color$reset))
      return("Tool execution denied by configuration")
    }

    preview <- corteza:::cli_tool_preview(name, args)
    cat(sprintf("\n%s●%s %s", color$cyan, color$reset,
                corteza:::cli_tool_label(name)))
    if (nzchar(preview)) {
      cat(sprintf("(%s)", preview))
    }
    cat("\n")
    detail_lines <- corteza:::cli_tool_detail_lines(name, args, cwd = cwd, width = 84L)
    if (length(detail_lines) > 0L) {
      for (line in detail_lines) {
        cat(sprintf("  %s%s%s\n", color$dim, line, color$reset))
      }
    }
    cat(sprintf("  %sRunning...%s\n", color$dim, color$reset))

    turn_number <<- turn_number + 1L
    start_time <- Sys.time()
    failed <- FALSE

    result <- tryCatch({
      cli_worker_call(worker, name, args)
    }, error = function(e) {
      # Try to reconnect on connection errors
      if (grepl("closed connection|SIGPIPE", e$message)) {
        cat(sprintf("%sreconnecting...%s ", color$dim, color$reset))
        worker <<- tryCatch(
          cli_worker_connect(opts$port),
          error = function(e2) NULL
        )
        if (!is.null(worker)) {
          # Retry the call
          return(tryCatch(
            cli_worker_call(worker, name, args),
            error = function(e2) {
              failed <<- TRUE
              cat(sprintf("  %s⎿ failed: %s%s\n",
                          color$bright_magenta, e2$message, color$reset))
              list(text = paste("Error:", e2$message))
            }
          ))
        }
      }
      failed <<- TRUE
      cat(sprintf("  %s⎿ failed: %s%s\n",
                  color$bright_magenta, e$message, color$reset))
      list(text = paste("Error:", e$message))
    })

    text <- result$text %||% ""
    elapsed_ms <- as.numeric(difftime(Sys.time(), start_time, units = "secs")) * 1000

    if (!failed) {
      lines <- length(strsplit(text, "\n")[[1]])
      cat(sprintf("  %s⎿%s %d line%s in %dms\n",
                  color$dim, color$reset,
                  lines,
                  if (identical(lines, 1L)) "" else "s",
                  round(elapsed_ms)))
      # Store in buffer for /last command
      tool_buffer_add(name, args, text)
    }

    # Record trace (CLI-side, in addition to MCP server side)
    tryCatch({
      corteza:::trace_add(
        session_id = session$sessionId,
        tool = name,
        args = args,
        result = text,
        success = !failed,
        elapsed_ms = round(elapsed_ms),
        approved_by = approved_by,
        turn = turn_number
      )
    }, error = function(e) {
      # Trace recording is best-effort
    })

    text
  }

  # Conversation state - restore from session
  provider <- opts$provider
  model <- opts$model

  # Display model
  display_model <- resolve_provider_model(provider, model)

  cat(sprintf("%s%s @ %s%s\n", color$dim, display_model, provider, color$reset))

  # Track estimated live context, not cumulative billed API usage.
  session_tokens <- estimate_live_context_tokens(session, system_prompt, tools)
  session$tokens <- session_tokens
  context_limit <- get_context_limit(display_model)

  # Show resumed message count
  if (length(session$messages) > 0) {
    cat(sprintf("%sResumed session with %d messages.%s\n",
                color$dim, length(session$messages), color$reset))
    if (session_tokens > 0) {
      print_context_indicator(session_tokens, context_limit,
                              opts$context_warn_pct, opts$context_high_pct, opts$context_crit_pct)
    }
  }

  # REPL
  while (TRUE) {
    prompt <- cli_read_line(sprintf("%s> %s", color$green, color$reset))

    # Handle EOF (Ctrl+D on Unix, Ctrl+Z+Enter on Windows)
    if (length(prompt) == 0) {
      cat("\n")
      break
    }

    prompt <- trimws(prompt)
    if (nchar(prompt) == 0) next

    # Handle commands
    if (startsWith(prompt, "/")) {
      cmd_parts <- strsplit(prompt, "\\s+")[[1]]
      cmd <- tolower(cmd_parts[1])
      cmd_arg <- if (length(cmd_parts) > 1) cmd_parts[2] else NULL
      cmd_rest <- trimws(sub("^/\\S+\\s*", "", prompt, perl = TRUE))

      if (cmd %in% c("/quit", "/exit", "/q")) {
        break
      } else if (cmd == "/tools") {
        cat(sprintf("\n%sAvailable tools:%s\n", color$bold, color$reset))
        for (tool in tools) {
          cat(sprintf("  %s%s%s - %s\n", color$cyan, tool$name, color$reset,
                      tool$description %||% ""))
        }
        cat("\n")
        next
      } else if (cmd == "/clear") {
        # Clear in-memory messages (transcript preserved on disk)
        session$messages <- list()
        session_tokens <- estimate_live_context_tokens(session, system_prompt, tools)
        session$tokens <- session_tokens
        corteza:::session_save(session)
        cat(sprintf("%sConversation cleared (transcript preserved).%s\n", color$dim, color$reset))
        next
      } else if (cmd == "/compact") {
        if (length(session$messages) < 2) {
          cat(sprintf("%sNothing to compact.%s\n", color$dim, color$reset))
        } else {
          compact_result <- do_compact(session, provider, model, system_prompt)
          if (!is.null(compact_result)) {
            # Write compaction marker to transcript
            corteza:::transcript_compact(session, compact_result$summary)

            # Update in-memory session
            session$messages <- list(
              list(role = "assistant", content = compact_result$summary, type = "compaction")
            )
            session_tokens <- estimate_live_context_tokens(session, system_prompt, tools)
            session$tokens <- session_tokens
            session$compactionCount <- (session$compactionCount %||% 0L) + 1L
            corteza:::session_save(session)
          }
        }
        next
      } else if (cmd == "/sessions") {
        sessions <- corteza:::session_list()
        cat(corteza:::format_session_list(sessions), "\n")
        next
      } else if (cmd == "/status") {
        config <- corteza:::load_config(cwd)
        cat(sprintf("\n%s%s%s\n\n",
                    color$bold,
                    format_status_summary(session, provider, display_model, tools,
                                          opts, config, session_tokens,
                                          context_limit, context_files,
                                          skill_docs),
                    color$reset))
        next
      } else if (cmd == "/doctor") {
        config <- corteza:::load_config(cwd)
        cat(sprintf("\n%s%s%s\n\n",
                    color$bold,
                    format_doctor_report(cwd, session, provider, display_model,
                                         tools, config, context_files,
                                         skill_docs),
                    color$reset))
        next
      } else if (cmd == "/config") {
        config <- corteza:::load_config(cwd)
        cat(sprintf("\n%s%s%s\n\n",
                    color$bold,
                    format_config_summary(config, provider, display_model, opts),
                    color$reset))
        next
      } else if (cmd == "/permissions") {
        config <- corteza:::load_config(cwd)
        approvals_path <- file.path(cwd, ".corteza", "approvals.json")
        cat(sprintf("\n%s%s%s\n",
                    color$bold,
                    corteza:::format_permissions(config),
                    color$reset))
        cat(sprintf("%sProject approvals:%s %s\n\n",
                    color$dim, color$reset,
                    if (file.exists(approvals_path)) approvals_path else "none"))
        next
      } else if (cmd == "/diff") {
        material <- collect_git_diff(if (nchar(cmd_rest) > 0) cmd_rest else NULL)
        if (!isTRUE(material$ok)) {
          cat(sprintf("%s%s%s\n", color$yellow, material$text, color$reset))
        } else {
          tool_buffer_add("git_diff", list(ref = material$target), material$diff)
          cat(sprintf("\n%sDiff against %s%s\n", color$cyan, material$target, color$reset))
          cat(material$diff, "\n")
        }
        next
      } else if (cmd == "/review") {
        material <- collect_git_diff(if (nchar(cmd_rest) > 0) cmd_rest else NULL)
        if (!isTRUE(material$ok)) {
          cat(sprintf("%s%s%s\n", color$yellow, material$text, color$reset))
          next
        }

        provider_check <- provider_status(provider, model)
        if (!isTRUE(provider_check$ok)) {
          cat(sprintf("%sReview unavailable: %s%s\n",
                      color$yellow, provider_check$message, color$reset))
          next
        }

        cat(sprintf("%sReviewing diff against %s...%s\n",
                    color$dim, material$target, color$reset))
        review_result <- run_review(provider, model, material$target,
                                    material$status, material$diff)
        if (inherits(review_result, "error")) {
          cat(sprintf("%sReview failed: %s%s\n",
                      color$bright_magenta, review_result$message,
                      color$reset))
        } else {
          tool_buffer_add("review", list(ref = material$target),
                          review_result$content)
          print_response(review_result$content)
        }
        next
      } else if (cmd == "/model" && nchar(cmd_rest) > 0) {
        model <- resolve_provider_model(provider, cmd_rest)
        display_model <- resolve_provider_model(provider, model)
        context_limit <- get_context_limit(display_model)
        session$model <- model
        corteza:::session_save(session)
        cat(sprintf("%sModel set to: %s%s\n", color$dim, model, color$reset))
        next
      } else if (cmd == "/provider" && nchar(cmd_rest) > 0) {
        provider <- cmd_rest
        display_model <- resolve_provider_model(provider, model) %||% "(default)"
        context_limit <- get_context_limit(display_model)
        session$provider <- provider
        corteza:::session_save(session)
        cat(sprintf("%sProvider set to: %s%s\n", color$dim, provider, color$reset))
        next
      } else if (cmd == "/context") {
        session_tokens <- estimate_live_context_tokens(session, system_prompt, tools)
        session$tokens <- session_tokens
        cat(sprintf("%sLive context:%s %s / %s tokens (auto-compact at %d%%)\n",
                    color$bold, color$reset,
                    format_tokens(session_tokens), format_tokens(context_limit),
                    opts$context_compact_pct))
        if (length(context_files) == 0) {
          cat(sprintf("%sNo context files loaded.%s\n", color$dim, color$reset))
          cat("Project context comes from saber::briefing() and saber::agent_context().\n")
          cat("Add files via the `context_files` config key.\n")
        } else {
          cat(sprintf("%sLoaded context files:%s\n", color$bold, color$reset))
          for (f in context_files) {
            cat(sprintf("  %s%s%s\n", color$cyan, f, color$reset))
          }
        }
        next
      } else if (cmd == "/remember") {
        # /remember <fact> #tags - Add to project memory with auto-categorization
        # /remember --global <fact> #tags - Add to global memory
        if (length(cmd_parts) < 2) {
          cat("Usage: /remember <fact> #optional #tags\n")
          cat("       /remember --global <fact> #tags\n")
          next
        }

        fact_text <- paste(cmd_parts[-1], collapse = " ")
        is_global <- startsWith(fact_text, "--global ")

        if (is_global) {
          fact_text <- sub("^--global ", "", fact_text)
          scope <- "global"
        } else {
          scope <- "project"
        }

        # Use the memory module for storage (handles tags, categorization)
        tryCatch({
          corteza:::memory_store(fact_text, scope = scope, cwd = cwd)
          clean_fact <- corteza:::strip_tags(fact_text)
          tags <- corteza:::parse_tags(fact_text)
          tag_str <- if (length(tags) > 0) sprintf(" [%s]", paste0("#", tags, collapse = " ")) else ""
          scope_str <- if (scope == "global") " (global)" else ""
          cat(sprintf("%sRemembered%s: %s%s%s\n", color$green, scope_str, clean_fact, tag_str, color$reset))
        }, error = function(e) {
          cat(sprintf("%sError: %s%s\n", color$red, e$message, color$reset))
        })

        # Reload context to pick up new memory
        system_prompt <- corteza:::load_context(cwd)
        next
      } else if (cmd == "/recall") {
        # /recall <query> - Search memory
        # /recall --tags - List all tags
        if (length(cmd_parts) < 2) {
          cat("Usage: /recall <query>   Search for memories\n")
          cat("       /recall --tags    List all memory tags\n")
          next
        }

        query <- paste(cmd_parts[-1], collapse = " ")

        if (query == "--tags") {
          tags <- corteza:::memory_list_tags(scope = "both", cwd = cwd)
          if (length(tags) == 0) {
            cat(sprintf("%sNo tags found.%s\n", color$dim, color$reset))
          } else {
            cat(sprintf("%sMemory tags:%s\n", color$bold, color$reset))
            cat(sprintf("  %s#%s%s\n", color$cyan, paste(tags, collapse = paste0(color$reset, ", ", color$cyan, "#")), color$reset))
          }
          next
        }

        # Search memory
        results <- corteza:::memory_search(query, scope = "both", cwd = cwd)
        if (length(results) == 0) {
          cat(sprintf("%sNo memories matching '%s'%s\n", color$dim, query, color$reset))
        } else {
          cat(sprintf("\n%sFound %d memor%s:%s\n", color$bold, length(results),
                      if (length(results) == 1) "y" else "ies", color$reset))
          formatted <- corteza:::format_memory_results(results)
          cat(formatted, "\n")
        }
        next
      } else if (cmd == "/flush") {
        # Manual memory flush: ask LLM to write durable memories
        flush_prompt <- config$memory_flush_prompt %||% paste0(
            "Pre-compaction memory flush. ",
            "Store durable memories now using write_file to memory/YYYY-MM-DD.md ",
            "in the workspace. Include: preferences discovered, decisions made, ",
            "technical details worth preserving. ",
            "If nothing to store, reply with exactly: NO_REPLY")

        if (length(session$messages) < 2) {
          cat(sprintf("%sNothing to flush (no conversation yet).%s\n", color$dim, color$reset))
          next
        }

        cat(sprintf("%sFlushing memories...%s\n", color$cyan, color$reset))

        # Build API history from current messages
        flush_history <- lapply(session$messages, function(m) {
          text <- if (is.list(m$content) && length(m$content) > 0 && !is.null(m$content[[1]]$text)) {
            m$content[[1]]$text
          } else {
            m$content
          }
          list(role = m$role, content = text)
        })

        flush_result <- tryCatch({
          agent(
            prompt = flush_prompt,
            tools = tools,
            tool_handler = tool_handler,
            system = system_prompt,
            model = resolve_provider_model(provider, model),
            provider = provider,
            history = flush_history,
            verbose = FALSE
          )
        }, error = function(e) {
          cat(sprintf("%sFlush failed: %s%s\n", color$bright_magenta, e$message, color$reset))
          NULL
        })

        if (!is.null(flush_result)) {
          content <- flush_result$content
          if (!startsWith(trimws(content), "NO_REPLY")) {
            cat(sprintf("%sMemories flushed.%s\n", color$green, color$reset))
            # Reload context to pick up new memory files
            system_prompt <- corteza:::load_context(cwd)
          } else {
            cat(sprintf("%sNothing to flush.%s\n", color$dim, color$reset))
          }
        }
        next
      } else if (cmd == "/last") {
        # /last [N] - show tool output (default: most recent)
        n <- if (!is.null(cmd_arg)) as.integer(cmd_arg) else 1L
        outputs <- tool_buffer_list()

        if (length(outputs) == 0) {
          cat(sprintf("%sNo tool outputs yet.%s\n", color$dim, color$reset))
          next
        }

        if (n < 1 || n > length(outputs)) {
          cat(sprintf("%sInvalid index. Have %d outputs.%s\n", color$yellow, length(outputs), color$reset))
          next
        }

        entry <- outputs[[n]]
        cat(sprintf("\n%s%s%s @ %s\n", color$cyan, entry$name, color$reset,
                    format(entry$time, "%H:%M:%S")))
        if (length(entry$args) > 0) {
          cat(sprintf("%sArgs: %s%s\n", color$dim,
                      jsonlite::toJSON(entry$args, auto_unbox = TRUE), color$reset))
        }
        cat(sprintf("%s%s%s\n", color$dim, strrep("-", 40), color$reset))
        cat(entry$result)
        cat("\n")
        next
      } else if (cmd == "/outputs") {
        # List recent tool outputs
        outputs <- tool_buffer_list()
        if (length(outputs) == 0) {
          cat(sprintf("%sNo tool outputs yet.%s\n", color$dim, color$reset))
        } else {
          cat(sprintf("\n%sRecent tool outputs:%s\n", color$bold, color$reset))
          for (i in seq_along(outputs)) {
            entry <- outputs[[i]]
            lines <- length(strsplit(entry$result, "\n")[[1]])
            cat(sprintf("  %s[%d]%s %s%s%s (%d lines) @ %s\n",
                        color$dim, i, color$reset,
                        color$cyan, entry$name, color$reset,
                        lines, format(entry$time, "%H:%M:%S")))
          }
          cat(sprintf("\n%sUse /last [N] to view output%s\n", color$dim, color$reset))
        }
        next
      } else if (cmd == "/dryrun") {
        # Toggle dry-run mode
        opts$dry_run <- !isTRUE(opts$dry_run)
        if (opts$dry_run) {
          cat(sprintf("%sDry-run mode enabled - tool calls will show preview only%s\n",
                      color$yellow, color$reset))
        } else {
          cat(sprintf("%sDry-run mode disabled - tool calls will execute normally%s\n",
                      color$dim, color$reset))
        }
        next
      } else if (cmd == "/trace") {
        # Show tool execution trace
        n <- if (!is.null(cmd_arg)) as.integer(cmd_arg) else 20L
        trace <- corteza:::trace_load(session$sessionId, n = n)
        if (length(trace) == 0) {
          cat(sprintf("%sNo tool calls recorded for this session.%s\n", color$dim, color$reset))
        } else {
          cat(corteza:::format_trace(trace, show_args = TRUE), "\n")
        }
        next
      } else if (cmd == "/skill" || cmd == "/skills") {
        # Skill management commands
        subcmd <- if (length(cmd_parts) > 1) cmd_parts[2] else "list"

        if (subcmd == "list") {
          tryCatch({
            skills <- corteza::skill_list_installed()
            cat(corteza:::format_skill_list(skills), "\n")
          }, error = function(e) {
            cat(sprintf("%sError: %s%s\n", color$red, e$message, color$reset))
          })
        } else if (subcmd == "install" && length(cmd_parts) >= 3) {
          source <- cmd_parts[3]
          force <- "--force" %in% cmd_parts
          tryCatch({
            name <- corteza::skill_install(source, force = force)
            cat(sprintf("%sInstalled skill: %s%s\n", color$green, name, color$reset))
            # Reload skills
            corteza:::load_skill_docs(corteza:::corteza_data_path("skills"))
            corteza:::load_skills(corteza:::corteza_data_path("skills"))
          }, error = function(e) {
            cat(sprintf("%sError: %s%s\n", color$red, e$message, color$reset))
          })
        } else if (subcmd == "remove" && length(cmd_parts) >= 3) {
          name <- cmd_parts[3]
          tryCatch({
            corteza::skill_remove(name)
            cat(sprintf("%sRemoved skill: %s%s\n", color$dim, name, color$reset))
          }, error = function(e) {
            cat(sprintf("%sError: %s%s\n", color$red, e$message, color$reset))
          })
        } else if (subcmd == "test" && length(cmd_parts) >= 3) {
          path <- cmd_parts[3]
          tryCatch({
            result <- corteza::skill_test(path)
            if (result$failed == 0) {
              cat(sprintf("%s%d test(s) passed%s\n", color$green, result$passed, color$reset))
            } else {
              cat(sprintf("%s%d passed, %d failed%s\n", color$yellow, result$passed, result$failed, color$reset))
            }
          }, error = function(e) {
            cat(sprintf("%sError: %s%s\n", color$red, e$message, color$reset))
          })
        } else {
          cat("Usage:
  /skill list                 List installed skills
  /skill install <path|url>   Install a skill
  /skill install <path> --force   Reinstall skill
  /skill remove <name>        Remove a skill
  /skill test <path>          Run skill tests
")
        }
        next
      } else if (cmd == "/spawn") {
        # Spawn a subagent
        if (length(cmd_parts) < 2) {
          cat("Usage: /spawn <task description>\n")
          cat("       /spawn <task> --model llama3.2\n")
          next
        }
        task_text <- paste(cmd_parts[-1], collapse = " ")

        # Parse --model if present
        subagent_model <- NULL
        if (grepl("--model", task_text)) {
          parts <- strsplit(task_text, "--model\\s+")[[1]]
          task_text <- trimws(parts[1])
          model_parts <- strsplit(trimws(parts[2]), "\\s+")[[1]]
          subagent_model <- model_parts[1]
        }

        tryCatch({
          id <- corteza::subagent_spawn(
            task = task_text,
            model = subagent_model,
            parent_session = session
          )
          cat(sprintf("%sSpawned subagent: %s%s\n", color$green, id, color$reset))
          cat(sprintf("%sUse /ask %s <prompt> to query%s\n", color$dim, id, color$reset))
        }, error = function(e) {
          cat(sprintf("%sError: %s%s\n", color$red, e$message, color$reset))
        })
        next
      } else if (cmd == "/agents") {
        # List active subagents
        agents <- corteza::subagent_list()
        cat(corteza:::format_subagent_list(agents), "\n")
        next
      } else if (cmd == "/ask" && length(cmd_parts) >= 3) {
        # Query a subagent: /ask <id> <prompt>
        subagent_id <- cmd_parts[2]
        subagent_prompt <- paste(cmd_parts[3:length(cmd_parts)], collapse = " ")

        cat(sprintf("%sQuerying subagent %s...%s\n", color$dim, subagent_id, color$reset))
        tryCatch({
          result <- corteza::subagent_query(subagent_id, subagent_prompt)
          cat(sprintf("%s%s%s\n", color$cyan, result, color$reset))
        }, error = function(e) {
          cat(sprintf("%sError: %s%s\n", color$red, e$message, color$reset))
        })
        next
      } else if (cmd == "/kill" && length(cmd_parts) >= 2) {
        # Kill a subagent
        subagent_id <- cmd_parts[2]
        tryCatch({
          success <- corteza::subagent_kill(subagent_id)
          if (success) {
            cat(sprintf("%sSubagent %s terminated%s\n", color$dim, subagent_id, color$reset))
          } else {
            cat(sprintf("%sSubagent not found: %s%s\n", color$yellow, subagent_id, color$reset))
          }
        }, error = function(e) {
          cat(sprintf("%sError: %s%s\n", color$red, e$message, color$reset))
        })
        next
      } else if (cmd == "/help") {
        cat("
Commands:
  /quit, /exit   Exit corteza
  /status        Show runtime and session status
  /doctor        Check provider, git, MCP, and context health
  /tools         List available tools
  /diff [ref]    Show git diff against HEAD or a ref
  /review [ref]  Review local changes with the current model
  /config        Show active runtime configuration
  /permissions   Show tool approval and sandbox settings
  /clear         Clear conversation (keeps session)
  /compact       Summarize conversation to free context
  /sessions      List sessions for this directory
  /context       Show live context usage and loaded context files
  /model <name>  Switch model
  /provider <p>  Switch provider (anthropic, openai, moonshot, ollama)
  /dryrun        Toggle dry-run mode (preview tools without executing)
  /trace [N]     Show last N tool executions (default: 20)

Memory:
  /remember <fact> #tags      Remember with auto-categorization
  /remember --global <fact>   Remember to global memory
  /recall <query>             Search memories
  /recall --tags              List all memory tags
  /flush                      Flush durable memories to daily log

Subagents:
  /spawn <task>               Spawn a subagent for task
  /spawn <task> --model x     Spawn with specific model
  /agents                     List active subagents
  /ask <id> <prompt>          Query a subagent
  /kill <id>                  Terminate a subagent

Skills:
  /skill list                 List installed skills
  /skill install <path|url>   Install a skill
  /skill remove <name>        Remove a skill
  /skill test <path>          Run skill tests

Tool output:
  /last [N]      Show tool output (1=most recent)
  /outputs       List recent tool outputs
  /help          Show this help

Legacy corteza memory injection is off by default.
Use config if you want MEMORY.md or memory logs back in prompt context.

")
        next
      } else {
        cat(sprintf("%sUnknown command: %s%s\n", color$yellow, cmd, color$reset))
        next
      }
    }

    # Build API history from session BEFORE adding current user message
    # (agent() adds the prompt itself)
    # Messages are stored in content-block format, extract text for API
    api_history <- lapply(session$messages, function(m) {
      # Extract text from content blocks (pi-coding-agent format)
      text <- if (is.list(m$content) && length(m$content) > 0 && !is.null(m$content[[1]]$text)) {
        m$content[[1]]$text
      } else {
        m$content  # Fallback for legacy format
      }
      list(role = m$role, content = text)
    })

    # Add user message to session (in-memory) and transcript (disk)
    session <- corteza:::session_add_message(session, "user", prompt)
    corteza:::transcript_append(session, "user", prompt)
    corteza:::session_save(session)

    # Send to LLM with tools
    # Check API key (not needed for Ollama)
    if (provider != "ollama") {
      .config <- llm.api:::.get_provider_config(provider)
      api_key <- .config$api_key %||% ""
      if (nchar(api_key) == 0) {
        cat(sprintf("%sWarning: No API key found for %s%s\n", color$yellow, provider, color$reset))
        env_var <- switch(provider,
          anthropic = "ANTHROPIC_API_KEY",
          openai = "OPENAI_API_KEY",
          paste0(toupper(provider), "_API_KEY")
        )
        cat(sprintf("Set %s in ~/.Renviron\n", env_var))
        next
      }
    }

    # Build a per-turn corteza session so policy gating + observers layer
    # on top of the existing CLI handler. The CLI still runs its own
    # approval UX inside tool_handler, so we pass an always-approve
    # approval_cb — policy "deny" verdicts still short-circuit (e.g.
    # for ~/.ssh), but "ask" verdicts fall through to the CLI's own
    # ask_approval flow.
    turn_session <- corteza::new_session(
      channel = "cli",
      provider = provider,
      model_map = list(cloud = resolve_provider_model(provider, model),
                       local = corteza::default_local_model()),
      system = system_prompt,
      history = api_history,
      approval_cb = function(call, decision) TRUE,
      max_turns = 20L
    )

    # CLI tool_handler returns plain strings; turn() expects MCP-format.
    # Wrap: any "Tool execution denied" / "[DRY RUN]" etc. is still a
    # valid text response, just inside the MCP shape.
    cli_executor <- function(name, args) {
      text <- tool_handler(name, args)
      if (is.character(text) && length(text) == 1L) {
        list(content = list(list(type = "text", text = text)))
      } else {
        # Tool handler already returned MCP format (mcp_call passthrough)
        text
      }
    }

    cat(sprintf("● Thinking with %s\n", display_model))
    result <- tryCatch({
      r <- corteza::turn(
        prompt = prompt,
        session = turn_session,
        tool_executor = cli_executor,
        tools = tools
      )
      # Reshape to the llm.api::agent result shape so downstream
      # compaction / token-tracking code keeps working unchanged.
      list(
        content = r$reply,
        history = turn_session$history,
        usage = r$usage
      )
    }, error = function(e) {
      cat(sprintf("%sError: %s%s\n", color$bright_magenta, e$message, color$reset))
      NULL
    })

    if (!is.null(result)) {
      print_response(result$content)

      session <- corteza:::session_add_message(session, "assistant", result$content)
      corteza:::transcript_append(session, "assistant", result$content)

      session_tokens <- estimate_live_context_tokens(session, system_prompt, tools)
      session$tokens <- session_tokens
      print_context_indicator(session_tokens, context_limit,
                              opts$context_warn_pct, opts$context_high_pct,
                              opts$context_crit_pct)

      # Auto-compact based on estimated live context, not cumulative API usage.
      pct <- (session_tokens / context_limit) * 100
      if (pct >= opts$context_compact_pct && length(session$messages) > 2) {
        # Pre-compaction memory flush
        flush_enabled <- config$memory_flush_enabled %||% TRUE
        compaction_count <- session$compactionCount %||% 0L
        flush_count <- session$memoryFlushCompactionCount %||% 0L
        if (isTRUE(flush_enabled) && flush_count <= compaction_count) {
          flush_prompt <- config$memory_flush_prompt %||% paste0(
              "Pre-compaction memory flush. ",
              "Store durable memories now using write_file to memory/YYYY-MM-DD.md ",
              "in the workspace. Include: preferences discovered, decisions made, ",
              "technical details worth preserving. ",
              "If nothing to store, reply with exactly: NO_REPLY")

          cat(sprintf("%sFlushing memories before compaction...%s\n", color$cyan, color$reset))

          flush_history <- lapply(session$messages, function(m) {
            text <- if (is.list(m$content) && length(m$content) > 0 && !is.null(m$content[[1]]$text)) {
              m$content[[1]]$text
            } else {
              m$content
            }
            list(role = m$role, content = text)
          })

          flush_result <- tryCatch({
            agent(
              prompt = flush_prompt,
              tools = tools,
              tool_handler = tool_handler,
              system = system_prompt,
              model = resolve_provider_model(provider, model),
              provider = provider,
              history = flush_history,
              verbose = FALSE
            )
          }, error = function(e) {
            cat(sprintf("%sFlush failed: %s%s\n", color$bright_magenta, e$message, color$reset))
            NULL
          })

          if (!is.null(flush_result)) {
            content <- flush_result$content
            if (!startsWith(trimws(content), "NO_REPLY")) {
              cat(sprintf("%sMemories flushed.%s\n", color$green, color$reset))
              system_prompt <- corteza:::load_context(cwd)
            }
          }

          session$memoryFlushCompactionCount <- compaction_count + 1L
        }

        compact_result <- do_compact(session, provider, model, system_prompt)
        if (!is.null(compact_result)) {
          # Write compaction marker to transcript (full history preserved)
          corteza:::transcript_compact(session, compact_result$summary)

          # Update in-memory session to use summary for next API call
          session$messages <- list(
            list(role = "assistant", content = compact_result$summary, type = "compaction")
          )
          session_tokens <- estimate_live_context_tokens(session, system_prompt, tools)
          session$tokens <- session_tokens
          session$compactionCount <- (session$compactionCount %||% 0L) + 1L
        }

        corteza:::session_save(session)
      } else {
        corteza:::session_save(session)
      }
    }
  }

  # Final save
  corteza:::session_save(session)

  # Cleanup
  cat(sprintf("\n%sGoodbye.%s\n", color$dim, color$reset))
  suppressWarnings(cli_worker_close(worker))
}

# ============================================================================
# Entry point
# ============================================================================

if (!interactive()) {
  opts <- parse_args()
  run_agent(opts)
}
