#!/usr/bin/env sh
# harsh — a portable shell agent harness.
#
# The core harness is fully functional alone.  Sessions are directories of a
# file per turn/entry + a manifest.csv. Deps: jq, curl, a shell.
#
# Usage: harsh.sh [-c CONFIG] [-q] COMMAND [ARGS...]
# See `harsh.sh help`.

set -u
# Make zsh behave like a POSIX shell when invoked as `zsh harsh.sh`.
if [ -n "${ZSH_VERSION:-}" ]; then
  emulate sh 2>/dev/null || setopt sh_word_split 2>/dev/null || true
fi

HARSH_VERSION=0.2.0
# SELF_DIR locates the checkout (repo-local config, sibling scripts). Data
# directories are NOT inferred from it — they come from the config.
SELF_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)
_config_file=""

# Presentation lives in lib/render.sh so the REPL and `show` share one look. It is
# optional: when absent, these inert fallbacks keep harsh.sh fully usable on its
# own — just without color or markdown. They cover only what harsh.sh calls
# directly (the colors it prints, plus the two block renderers).
if [ -f "${SELF_DIR}/lib/render.sh" ]; then
  # shellcheck disable=SC1091
  . "${SELF_DIR}/lib/render.sh"
else
  C_DIM=; C_RST=; C_USER=; C_TOOL=; C_BAR=; GUTTER='|'
  render_assistant() {
    [ -n "$(printf '%s' "$1" | tr -d '[:space:]')" ] || return 0
    printf 'harsh\n'; printf '%s\n' "$1" | sed 's/^/  /'
  }
  render_tool_result() {
    printf '#%s %s %s\n' "$1" "$2" "$3"
    { [ "$5" = true ] || [ -n "${HARSH_VERBOSE:-}" ]; } && printf '%s\n' "$4" | sed 's/^/  /'
    return 0
  }
fi

# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
die() { printf 'harsh: %s\n' "$*" >&2; exit 1; }
say() { [ -n "${HARSH_QUIET:-}" ] || printf '%s\n' "$*"; }
# warn() → stderr, so diagnostics survive command substitution (e.g. call_api,
# whose stdout is captured by the caller).
warn() { printf '%s\n' "$*" >&2; }
have() { command -v "$1" >/dev/null 2>&1; }
# jqv VALUE [jq-args...] — run jq over a JSON VALUE held in a shell variable,
# instead of the noisy `printf '%s' "$x" | jq …` at every call site.
jqv() { _jqv=$1; shift; printf '%s' "${_jqv}" | jq "$@"; }

load_config() {
  export SELF_DIR   # so config files can reference it: HARSH_TOOLS_DIR="$SELF_DIR/tools"
  _cfg=${HARSH_CONFIG:-}
  if [ -z "${_cfg}" ]; then
    for _c in ./harsh.conf "${SELF_DIR}/harsh.conf" "${HOME}/.config/harsh/harsh.conf"; do
      [ -f "${_c}" ] && { _cfg=${_c}; break; }
    done
  fi
  if [ -n "${_cfg}" ] && [ -f "${_cfg}" ]; then
    # shellcheck disable=SC1090
    . "${_cfg}"
    _config_file=${_cfg}
  fi
  # Provider picks the wire format (anthropic Messages API | openai Chat
  # Completions). Model, endpoint, and key-env default per provider; all are
  # overridable in the config or environment.
  : "${HARSH_PROVIDER:=anthropic}"
  case "${HARSH_PROVIDER}" in
    openai)
      : "${HARSH_MODEL:=gpt-4o}"
      : "${HARSH_API_URL:=https://api.openai.com/v1/chat/completions}"
      HARSH_API_KEY=${HARSH_API_KEY:-${OPENAI_API_KEY:-}}
      ;;
    anthropic)
      : "${HARSH_MODEL:=claude-opus-4-8}"
      : "${HARSH_API_URL:=https://api.anthropic.com/v1/messages}"
      HARSH_API_KEY=${HARSH_API_KEY:-${ANTHROPIC_API_KEY:-}}
      ;;
    *) die "unknown HARSH_PROVIDER: ${HARSH_PROVIDER} (expected anthropic or openai)" ;;
  esac
  : "${HARSH_MAX_TOKENS:=8192}"
  : "${HARSH_API_VERSION:=2023-06-01}"
  # Prompt caching (Anthropic only) on by default — see build_request. 0 disables.
  : "${HARSH_CACHE:=1}"
  # Transient API failures (network, 408/429/5xx) are retried with exponential
  # backoff: HARSH_RETRIES attempts, starting at HARSH_RETRY_DELAY seconds.
  : "${HARSH_RETRIES:=3}"
  : "${HARSH_RETRY_DELAY:=2}"
  # Data directories must be set explicitly (config or env) — never inferred.
  for _v in HARSH_TOOLS_DIR HARSH_SKILLS_DIR HARSH_SESSIONS_DIR HARSH_LOG_DIR; do
    eval "_val=\${${_v}:-}"
    [ -n "${_val}" ] || die "${_v} is not set; define it in ${_cfg} (see harsh.conf)"
  done
  : "${HARSH_MAX_TURNS:=127}"
  # Auto-compaction: when the last turn's context exceeds this many tokens,
  # cmd_run invokes the drop-in `compact` command, which rewrites the live
  # view to a summary (the full log stays in the session dir). 0 disables.
  : "${HARSH_COMPACT_AT:=150000}"
  case "${HARSH_COMPACT_AT}" in *[!0-9]*) die "HARSH_COMPACT_AT must be a number (tokens), got: ${HARSH_COMPACT_AT}" ;; esac
  # Hooks/commands/lib are optional; defaults sit next to harsh.sh. A missing
  # hooks or commands dir simply means none are installed.
  : "${HARSH_HOOKS_DIR:=${SELF_DIR}/hooks}"
  : "${HARSH_COMMANDS_DIR:=${SELF_DIR}/commands}"
  : "${HARSH_LIB_DIR:=${SELF_DIR}/lib}"
  : "${HARSH_SYSTEM_PROMPT:=You are a concise and capable assistant operating inside harsh, a coding agent harness. Prefer small, verifiable steps. When the task is complete, stop and summarize.}"
  # (HARSH_API_KEY was resolved per-provider above.)
  # Expose the harness path and resolved config to tool subprocesses, so a tool
  # (e.g. tools/agent.sh) can re-invoke harsh for a sub-session with the same
  # config. HARSH_CONFIG is pinned to the loaded file so children don't re-discover.
  HARSH_SELF="${SELF_DIR}/harsh.sh"
  HARSH_CONFIG=${_config_file}
  export HARSH_PROVIDER HARSH_MODEL HARSH_MAX_TOKENS HARSH_CACHE HARSH_API_URL HARSH_API_VERSION \
         HARSH_TOOLS_DIR HARSH_SKILLS_DIR HARSH_SESSIONS_DIR HARSH_LOG_DIR \
         HARSH_HOOKS_DIR HARSH_COMMANDS_DIR HARSH_LIB_DIR \
         HARSH_MAX_TURNS HARSH_SYSTEM_PROMPT HARSH_API_KEY \
         HARSH_RETRIES HARSH_RETRY_DELAY HARSH_COMPACT_AT \
         HARSH_SELF HARSH_CONFIG HARSH_VERSION
  have jq || die "jq is required"
}

# Resolve a session argument (a bare name -> under sessions dir; a path -> as is)
session_dir() {
  _s=$1
  case "${_s}" in
    /*|./*|../*|*/*) printf '%s' "${_s}" ;;
    *)              printf '%s/%s' "${HARSH_SESSIONS_DIR}" "${_s}" ;;
  esac
}

# Next zero-padded sequence number for a session directory.
next_seq() {
  _dir=$1
  _n=0
  for _f in "${_dir}"/[0-9]*.json; do
    [ -e "${_f}" ] && _n=$((_n + 1))
  done
  printf '%04d' $((_n + 1))
}

# Append a conversation entry: one file holding {role, block[, meta]} plus a
# manifest line. The optional META_JSON carries per-turn response metadata
# (usage, stop_reason, model, id, …) — it is preserved in the session record but
# deliberately ignored by cmd_assemble, so it never reaches the API request.
#   add_entry DIR ROLE TYPE NAME BLOCK_JSON [META_JSON]
add_entry() {
  _dir=$1; _role=$2; _type=$3; _name=$4; _block=$5; _meta=${6:-}
  _seq=$(next_seq "${_dir}")
  if [ -n "${_name}" ]; then
    _safe=$(printf '%s' "${_name}" | tr -c 'A-Za-z0-9_.-' '_')
    _file="${_seq}-${_role}-${_type}-${_safe}.json"
  else
    _file="${_seq}-${_role}-${_type}.json"
  fi
  if [ -n "${_meta}" ] && [ "${_meta}" != null ] && [ "${_meta}" != '{}' ]; then
    jq -nc --arg role "${_role}" --argjson block "${_block}" --argjson meta "${_meta}" \
      '{role:$role, block:$block, meta:$meta}' \
      > "${_dir}/${_file}" || die "failed to write entry (invalid block/meta json)"
  else
    jq -nc --arg role "${_role}" --argjson block "${_block}" '{role:$role,block:$block}' \
      > "${_dir}/${_file}" || die "failed to write entry (invalid block json)"
  fi
  _ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
  printf '%s,%s,%s,%s,%s,%s,%s\n' "${_seq}" "${_role}" "${_type}" "${_name}" "${_file}" "${_ts}" "ok" \
    >> "${_dir}/manifest.csv"
}

# run_hooks EVENT PAYLOAD_JSON [TOOL] — feed PAYLOAD_JSON on stdin to each *.sh
# under $HARSH_HOOKS_DIR/$EVENT (and the $EVENT/$TOOL subdir, if TOOL given), in
# order. Hook exit codes (the Claude Code contract): 2 = block (its stdout is the
# reason; stops and returns 2); 0 = allow (stdout collected as context); other =
# error, logged to hooks.log and ignored. On allow, prints the context, returns 0.
run_hooks() {
  _event=$1; _payload=$2; _tool=${3:-}
  _base="${HARSH_HOOKS_DIR}/${_event}"
  _ctx=""
  mkdir -p "${HARSH_LOG_DIR}" 2>/dev/null || true
  # Scan the event dir (runs for everything), then the tool-specific subdir.
  for _d in "${_base}" "${_tool:+${_base}/${_tool}}"; do
    { [ -n "${_d}" ] && [ -d "${_d}" ]; } || continue
    for _h in "${_d}"/*.sh; do
      [ -f "${_h}" ] || continue
      _out=$(printf '%s' "${_payload}" | sh "${_h}" 2>>"${HARSH_LOG_DIR}/hooks.log"); _rc=$?
      case ${_rc} in
        0) [ -n "${_out}" ] && _ctx="${_ctx}${_out}
" ;;
        2) printf '%s' "${_out}"; return 2 ;;
        *) warn "[hook] ${_event}/$(basename "${_h}") exited ${_rc} (ignored)" ;;
      esac
    done
  done
  printf '%s' "${_ctx}"
  return 0
}

# Locate a command by name on a given SURFACE and print its script path (else
# return 1). A command at the top level of $HARSH_COMMANDS_DIR is available on
# every surface; one inside the SURFACE subdir (cli/ or repl/) is available only
# there — placement is the declaration, the same way hooks narrow scope with a
# subdirectory. Names are sanitized to forbid path traversal.
resolve_command() {
  _surface=$1
  _safe=$(printf '%s' "$2" | tr -cd 'A-Za-z0-9_-')
  [ -n "${_safe}" ] || return 1
  for _p in "${HARSH_COMMANDS_DIR}/${_safe}.sh" "${HARSH_COMMANDS_DIR}/${_surface}/${_safe}.sh"; do
    [ -f "${_p}" ] && { printf '%s' "${_p}"; return 0; }
  done
  return 1
}

# Run a command on the repl surface (top level + repl/). REPL convenience.
run_command() {
  _p=$(resolve_command repl "$1") || return 127
  shift
  sh "${_p}" "$@"
}

# Print "NAME<TAB>description" (via --describe) for the top level plus the SURFACE
# subdir. Default cli — the CLI sees top-level + cli/ commands; pass repl for the
# REPL set (top-level + repl/).
list_commands() {
  _surface=${1:-cli}
  for _d in "${HARSH_COMMANDS_DIR}" "${HARSH_COMMANDS_DIR}/${_surface}"; do
    [ -d "${_d}" ] || continue
    for _c in "${_d}"/*.sh; do
      [ -f "${_c}" ] || continue
      sh "${_c}" --describe 2>/dev/null || printf '%s\t(no description)\n' "$(basename "${_c}" .sh)"
    done
  done
}

# True if the command at PATH takes a SESSION as its first argument — read from
# its --describe usage, so the REPL can fill in the current session for
# session-scoped commands (e.g. /show) while leaving session-less ones alone.
command_wants_session() {
  case " $(sh "$1" --describe 2>/dev/null | cut -f1) " in *' SESSION'*) return 0 ;; *) return 1 ;; esac
}

# ---------------------------------------------------------------------------
# commands (engine primitives; derived commands live in $HARSH_COMMANDS_DIR)
# ---------------------------------------------------------------------------
cmd_init() {
  _name=${1:-sess-$(date -u +%Y%m%d-%H%M%S)}
  _dir=$(session_dir "${_name}")
  _fresh=0
  [ -d "${_dir}" ] || _fresh=1
  mkdir -p "${_dir}"
  [ -f "${_dir}/manifest.csv" ] || : > "${_dir}/manifest.csv"
  # SessionStart fires once per new session. Its output (if any) is injected as
  # opening context — captured here so it never reaches stdout, which is the
  # session directory path the callers consume.
  if [ "${_fresh}" = 1 ]; then
    _hp=$(jq -nc --arg e SessionStart --arg s "${_dir}" '{event:$e,session_dir:$s}')
    _hc=$(run_hooks SessionStart "${_hp}") || true
    [ -n "${_hc}" ] && add_entry "${_dir}" user text "" \
      "$(jq -nc --arg t "${_hc}" '{type:"text",text:$t}')" '{"context":"SessionStart"}'
  fi
  printf '%s\n' "${_dir}"
}

cmd_path() { session_dir "$1"; }

cmd_send() {
  _dir=$(session_dir "$1"); shift; _text=$*
  [ -d "${_dir}" ] || die "no such session: ${_dir} (run: harsh.sh init)"
  # UserPromptSubmit — a hook may reject the prompt (exit 2) or emit context that
  # is injected just before it (consecutive user blocks merge into one message).
  _hp=$(jq -nc --arg e UserPromptSubmit --arg s "${_dir}" --arg p "${_text}" \
        '{event:$e,session_dir:$s,prompt:$p}')
  if ! _hc=$(run_hooks UserPromptSubmit "${_hp}"); then
    warn "[blocked] prompt rejected by hook: ${_hc}"
    return 1
  fi
  [ -n "${_hc}" ] && add_entry "${_dir}" user text "" "$(jq -nc --arg t "${_hc}" '{type:"text",text:$t}')"
  _block=$(jq -nc --arg t "${_text}" '{type:"text",text:$t}')
  add_entry "${_dir}" user text "" "${_block}"
}

# Assemble the live conversation into a Messages-API `messages` array by
# grouping consecutive same-role blocks into one message.
#
# The manifest — not the directory — defines the live context: entry files are
# an immutable, append-only log (they are never moved or renumbered), and
# manifest.csv is the ordered view over them that context construction reads.
# Rewriting the manifest (see cmd_remanifest / commands/compact.sh) changes
# what the model sees next; the full log stays put for replay (`show`).
cmd_assemble() {
  _dir=$(session_dir "$1")
  [ -f "${_dir}/manifest.csv" ] || { printf '[]'; return 0; }
  set --
  # shellcheck disable=SC2034  # named for clarity; only _file is used
  while IFS=, read -r _seq _role _type _name _file _ts _status; do
    [ -n "${_file}" ] && [ -f "${_dir}/${_file}" ] && set -- "$@" "${_dir}/${_file}"
  done < "${_dir}/manifest.csv"
  [ $# -gt 0 ] || { printf '[]'; return 0; }
  jq -s 'reduce .[] as $e ([];
      if (length > 0) and (.[-1].role == $e.role)
      then (.[0:-1] + [(.[-1] | .content += [$e.block])])
      else (. + [{role: $e.role, content: [$e.block]}])
      end)' "$@"
}

# Streaming is opt-in (HARSH_STREAM=1) and Anthropic-only: OpenAI's delta
# format differs and is not wired up. The mock never streams.
stream_on() {
  [ "${HARSH_STREAM:-0}" = 1 ] && [ "${HARSH_PROVIDER}" = anthropic ] && [ -z "${HARSH_MOCK:-}" ]
}

# Fold a stream of Anthropic SSE event objects (JSON, one per line, on stdin)
# back into the canonical non-streaming response shape: message_start carries
# the skeleton, content_block_start/delta build the blocks (text appends;
# tool_use input arrives as partial JSON), message_delta carries stop_reason
# and the output-side usage. Exposed as `harsh.sh stream-assemble` (raw SSE on
# stdin) so the transform is testable offline.
stream_assemble() {
  jq -s '
    def finalize: if .type == "tool_use" and has("_pj")
                  then (.input = ((._pj | fromjson?) // {})) | del(._pj)
                  else . end;
    reduce .[] as $e (
      {base: {}, blocks: [], stop: null, dusage: {}};
      if $e.type == "message_start" then .base = ($e.message // {})
      elif $e.type == "content_block_start" then
        .blocks[$e.index] = ($e.content_block // {})
      elif $e.type == "content_block_delta" then
        if $e.delta.type == "text_delta" then
          .blocks[$e.index].text = ((.blocks[$e.index].text // "") + $e.delta.text)
        elif $e.delta.type == "input_json_delta" then
          .blocks[$e.index]._pj = ((.blocks[$e.index]._pj // "") + $e.delta.partial_json)
        else . end
      elif $e.type == "message_delta" then
        (.stop = ($e.delta.stop_reason // .stop)) | (.dusage = ($e.usage // {}))
      else . end)
    | .base
      + {content: (.blocks | map(select(. != null) | finalize)),
         stop_reason: (.stop // .base.stop_reason),
         usage: ((.base.usage // {}) + .dusage)}'
}

# Approximate size (tokens) of the LIVE conversation as the API last saw it:
# the most recent turn's usage covers the whole request context, plus what the
# model added on top. Reads the manifest, not the directory — after a manifest
# rewrite the retired turns' usage must not re-trigger compaction. Prints 0
# when no live turn has usage yet.
last_context_tokens() {
  _dir=$1
  [ -f "${_dir}/manifest.csv" ] || { printf '0'; return 0; }
  set --
  # shellcheck disable=SC2034  # named for clarity; only _file is used
  while IFS=, read -r _seq _role _type _name _file _ts _status; do
    [ -n "${_file}" ] && [ -f "${_dir}/${_file}" ] && set -- "$@" "${_dir}/${_file}"
  done < "${_dir}/manifest.csv"
  [ $# -gt 0 ] || { printf '0'; return 0; }
  jq -s '
    [.[] | .meta.usage // empty] | last // {}
    | (.input_tokens // 0) + (.cache_read_input_tokens // 0)
      + (.cache_creation_input_tokens // 0) + (.output_tokens // 0)' "$@"
}

# Rewrite the live manifest as a new generation. Entry files are an immutable,
# append-only log; the manifest is just the ordered view over them that
# context construction (cmd_assemble) reads — so "compaction", pinning, and
# any other context-editing scheme reduce to: write a new view.
#
# Reads ONE spec (JSON) on stdin:
#   { "manifest": ["0003", "@summary", "0007-user-text.json"],
#     "entries":  { "summary": { "role":"user",
#                                "block":{"type":"text","text":"…"},
#                                "meta":{"context":"compact"} } } }
# manifest: the new view, in order — existing entries by seq or filename, new
# ones as "@KEY". entries: the new entries to compose, keyed for reference;
# every key must be referenced (and every @KEY defined). New entries are
# materialized as ordinary NNNN-*.json files, so the log stays uniform.
#
# Non-destructive by construction: the outgoing view is retired to
# manifest-<ts>.csv and no entry file is ever touched, so any rewrite can be
# undone by rewriting again. The engine validates references and atomicity
# only — whether a view makes conversational sense (e.g. no orphaned
# tool_result) is the calling policy's judgment (see commands/compact.sh).
# Prints the retired generation's path.
#   cmd_remanifest SESSION   (spec on stdin)
cmd_remanifest() {
  _sess=$1
  _dir=$(session_dir "${_sess}")
  [ -d "${_dir}" ] || die "no such session: ${_dir}"
  _spec=$(cat)
  jqv "${_spec}" -e '(.manifest | type == "array")' >/dev/null 2>&1 \
    || die "remanifest: spec must have a manifest array"
  # Keys must be word-safe (they travel through shell loops), every @KEY
  # referenced must be defined, and every defined entry must be referenced.
  # shellcheck disable=SC2016  # $def/$ref are jq variables
  jqv "${_spec}" -e '
      ((.entries // {}) | keys) as $def
      | ([.manifest[] | select(startswith("@")) | .[1:]]) as $ref
      | ($def | all(test("^[A-Za-z0-9_-]+$")))
        and ($ref - $def == []) and ($def - $ref == [])' >/dev/null 2>&1 \
    || die "remanifest: @ refs and entries keys must match exactly (keys: [A-Za-z0-9_-]+)"

  # Resolve every existing-entry reference (seq or filename) to a file BEFORE
  # mutating anything, so a bad spec changes nothing. (Refs are seqs or
  # sanitized filenames — never contain whitespace.)
  for _ref in $(jqv "${_spec}" -r '.manifest[] | select(startswith("@") | not)'); do
    _resolve_ref "${_dir}" "${_ref}" >/dev/null || die "remanifest: no such entry: ${_ref}"
  done

  # Snapshot the outgoing view first; entries materialized below land in the
  # log (and append rows to the live manifest, which is about to be replaced).
  _gen="${_dir}/manifest-$(date -u +%Y%m%dT%H%M%SZ).csv"
  cp "${_dir}/manifest.csv" "${_gen}" 2>/dev/null || : > "${_gen}"

  # Materialize the new entries and remember key -> filename.
  _map=""
  for _key in $(jqv "${_spec}" -r '(.entries // {}) | keys[]'); do
    # shellcheck disable=SC2016  # $k is a jq variable
    _e=$(jqv "${_spec}" -c --arg k "${_key}" '.entries[$k]')
    _before=$(next_seq "${_dir}")
    add_entry "${_dir}" \
      "$(jqv "${_e}" -r '.role // "user"')" \
      "$(jqv "${_e}" -r '.block.type // "text"')" \
      "$(jqv "${_e}" -r '.block.name // ""')" \
      "$(jqv "${_e}" -c '.block')" \
      "$(jqv "${_e}" -c '.meta // empty')"
    set -- "${_dir}/${_before}"-*.json
    _map="${_map}${_key} $(basename "$1")
"
  done

  # Write the new view, then move it into place (same dir, so the swap is as
  # atomic as the filesystem allows). Each row is derived from the entry file
  # itself — the spec never supplies row data, only references.
  _tmp="${_dir}/.manifest.tmp.$$"
  : > "${_tmp}"
  for _ref in $(jqv "${_spec}" -r '.manifest[]'); do
    case "${_ref}" in
      @*)
        _file=$(printf '%s' "${_map}" | while IFS=' ' read -r _k _v; do
                  [ "${_k}" = "${_ref#@}" ] && { printf '%s' "${_v}"; break; }
                done) ;;
      *) _file=$(_resolve_ref "${_dir}" "${_ref}") ;;
    esac
    _seq=${_file%%-*}
    jq -r --arg seq "${_seq}" --arg file "${_file}" \
       --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
      '[$seq, .role, .block.type,
        ((.block.name // "") | gsub("[^A-Za-z0-9_.-]"; "_")),
        $file, $ts, "ok"] | join(",")' \
      "${_dir}/${_file}" >> "${_tmp}"
  done
  mv "${_tmp}" "${_dir}/manifest.csv"
  printf '%s\n' "${_gen}"
}

# Resolve an entry reference — a seq ("7", "0007") or a filename — to the
# entry's filename in DIR. Prints it, or returns 1.
_resolve_ref() {
  _rdir=$1; _r=$2
  case "${_r}" in
    *.json) [ -f "${_rdir}/${_r}" ] && { printf '%s' "${_r}"; return 0; } ;;
    *[!0-9]*) return 1 ;;
    *)
      _rs=$(printf '%04d' "${_r}")
      set -- "${_rdir}/${_rs}"-*.json
      [ -e "$1" ] && { basename "$1"; return 0; } ;;
  esac
  return 1
}

# Call the model. Honors HARSH_MOCK for offline smoke testing.
call_api() {
  _req=$1; _dir=$2
  mkdir -p "${HARSH_LOG_DIR}"
  _base=$(basename "${_dir}")
  printf '%s\n' "${_req}" >> "${HARSH_LOG_DIR}/${_base}.request.log"
  if [ -n "${HARSH_MOCK:-}" ]; then
    _resp=$(mock_api "${_req}")
    printf '%s\n' "${_resp}" >> "${HARSH_LOG_DIR}/${_base}.response.log"
    printf '%s' "${_resp}"
    return 0
  fi
  [ -n "${HARSH_API_KEY}" ] || {
    warn "[error] no API key set — export ANTHROPIC_API_KEY (or HARSH_API_KEY), or set HARSH_MOCK=1 for offline testing."
    return 1
  }
  # Provider auth differs: OpenAI uses a Bearer token; Anthropic uses x-api-key
  # plus the dated anthropic-version header. Headers are passed to curl through
  # a private file (-H @file), never argv, so the key is not visible in `ps`;
  # umask 077 keeps the file owner-only for its short life.
  _hdr=$(umask 077; mktemp 2>/dev/null || echo "/tmp/harsh_hdr.$$")
  _bodyf=$(umask 077; mktemp 2>/dev/null || echo "/tmp/harsh_body.$$")
  if [ "${HARSH_PROVIDER}" = openai ]; then
    printf 'authorization: Bearer %s\ncontent-type: application/json\n' \
      "${HARSH_API_KEY}" > "${_hdr}"
  else
    printf 'x-api-key: %s\nanthropic-version: %s\ncontent-type: application/json\n' \
      "${HARSH_API_KEY}" "${HARSH_API_VERSION}" > "${_hdr}"
  fi
  # Streaming path: ask for SSE, print text deltas to stderr as they arrive
  # (stdout stays the captured response), and fold the event stream back into
  # the canonical response shape afterwards. No retry loop here — a stream
  # failure surfaces as the provider's error body via the same error path.
  if stream_on; then
    _evf=$(umask 077; mktemp 2>/dev/null || echo "/tmp/harsh_sse.$$"); : > "${_evf}"
    printf '%s' "${_req}" | jq -c '. + {stream:true}' | curl -sS --no-buffer -X POST "${HARSH_API_URL}" \
        -H @"${_hdr}" --data-binary @- 2>>"${HARSH_LOG_DIR}/curl.log" \
      | while IFS= read -r _sline; do
          printf '%s\n' "${_sline}" >> "${_evf}"
          case "${_sline}" in
            'data: '*'"text_delta"'*)
              printf '%s' "${_sline#data: }" | jq -rj '.delta.text // empty' >&2 ;;
          esac
        done
    rm -f "${_hdr}"
    [ -s "${_evf}" ] || { rm -f "${_evf}" "${_bodyf}"; warn "[error] streaming request to ${HARSH_API_URL} returned nothing"; return 1; }
    printf '\n' >&2
    if grep -q '^data: ' "${_evf}"; then
      _resp=$(sed -n 's/^data: //p' "${_evf}" | stream_assemble)
    else
      # Not SSE: an HTTP-level error body — pass it through to the error path.
      _resp=$(cat "${_evf}")
    fi
    rm -f "${_evf}" "${_bodyf}"
    printf '%s\n' "${_resp}" >> "${HARSH_LOG_DIR}/${_base}.response.log"
    printf '%s' "${_resp}"
    return 0
  fi
  # Transient failures (network, timeouts, 408/429/5xx — including Anthropic's
  # 529 overloaded) retry with exponential backoff. Other non-2xx responses are
  # passed through: cmd_step surfaces .error.message from the body.
  _attempt=0; _delay=${HARSH_RETRY_DELAY}
  while :; do
    _code=$(printf '%s' "${_req}" | curl -sS -X POST "${HARSH_API_URL}" \
        -H @"${_hdr}" --data-binary @- \
        -o "${_bodyf}" -w '%{http_code}' 2>>"${HARSH_LOG_DIR}/curl.log") || _code=000
    case "${_code}" in
      2*) break ;;
      ''|000|408|429|5*)
        _attempt=$((_attempt + 1))
        if [ "${_attempt}" -gt "${HARSH_RETRIES}" ]; then
          rm -f "${_hdr}" "${_bodyf}"
          warn "[error] request to ${HARSH_API_URL} failed after ${HARSH_RETRIES} retries (HTTP ${_code:-000})"
          return 1
        fi
        warn "[retry] HTTP ${_code:-000} from API — attempt ${_attempt}/${HARSH_RETRIES}, waiting ${_delay}s"
        sleep "${_delay}"
        _delay=$((_delay * 2)) ;;
      *) break ;;
    esac
  done
  rm -f "${_hdr}"
  _resp=$(cat "${_bodyf}"); rm -f "${_bodyf}"
  printf '%s\n' "${_resp}" >> "${HARSH_LOG_DIR}/${_base}.response.log"
  printf '%s' "${_resp}"
}

# Offline mock model: echoes text, or emits a tool call when the last user
# message contains a [[tool:NAME:ARG]] marker. Lets the loop be smoke-tested.
mock_api() {
  _req=$1
  # The latest input to respond to is the last message — a user turn (Anthropic
  # content is an array of blocks; OpenAI content is a string) or, after a tool
  # ran, the tool result (Anthropic tool_result blocks carry no text; OpenAI is a
  # role:"tool" string). Pulling only text means a post-tool turn yields "" and
  # the mock stops instead of re-firing the tool. (Selecting role=="user" would
  # miss OpenAI tool results, which are role:"tool", and loop forever.)
  _last=$(jqv "${_req}" -r '
    (.messages[-1].content // []) |
    if type=="array" then (map(select(.type=="text").text) | join(" ")) else (.|tostring) end')
  case "${_last}" in
    # Failure-path fixtures, so tests can exercise the engine's error handling
    # offline: an API error body, a max_tokens truncation, and a parallel
    # multi-tool turn.
    *'[[mock:error]]'*)
      if [ "${HARSH_PROVIDER}" = openai ]; then
        jq -n '{error:{message:"mock API error",type:"invalid_request_error"}}'
      else
        jq -n '{type:"error",error:{type:"invalid_request_error",message:"mock API error"}}'
      fi ;;
    *'[[mock:truncate]]'*)
      if [ "${HARSH_PROVIDER}" = openai ]; then
        jq -n '{id:"chatcmpl_mock1", model:"mock-openai",
          choices:[{message:{role:"assistant", content:"partial reply cut", tool_calls:null}, finish_reason:"length"}],
          usage:{prompt_tokens:10, completion_tokens:5, prompt_tokens_details:{cached_tokens:0}}}'
      else
        jq -n '{content:[{type:"text",text:"partial reply cut"}],stop_reason:"max_tokens",
          model:"mock-model", id:"msg_mock1", role:"assistant", type:"message",
          usage:{input_tokens:10, output_tokens:5, cache_read_input_tokens:0, cache_creation_input_tokens:0}}'
      fi ;;
    *'[[mock:multitool]]'*)
      if [ "${HARSH_PROVIDER}" = openai ]; then
        jq -n '{id:"chatcmpl_mock1", model:"mock-openai",
          choices:[{message:{role:"assistant", content:"two at once",
            tool_calls:[
              {id:"call_mockA", type:"function", function:{name:"bash", arguments:"{\"command\":\"echo one\"}"}},
              {id:"call_mockB", type:"function", function:{name:"bash", arguments:"{\"command\":\"echo two\"}"}}]},
            finish_reason:"tool_calls"}],
          usage:{prompt_tokens:10, completion_tokens:5, prompt_tokens_details:{cached_tokens:0}}}'
      else
        jq -n '{content:[
            {type:"text",text:"two at once"},
            {type:"tool_use",id:"toolu_mockA",name:"bash",input:{command:"echo one"}},
            {type:"tool_use",id:"toolu_mockB",name:"bash",input:{command:"echo two"}}],
          stop_reason:"tool_use"}'
      fi ;;
    *'[[tool:'*']]'*)
      _spec=${_last#*'[[tool:'}; _spec=${_spec%%']]'*}
      _tname=${_spec%%:*}; _targs=${_spec#*:}
      if [ "${HARSH_PROVIDER}" = openai ]; then
        jq -n --arg n "${_tname}" --arg a "${_targs}" '{
          id:"chatcmpl_mock1", model:"mock-openai",
          choices:[{message:{role:"assistant", content:("Calling tool " + $n),
            tool_calls:[{id:"call_mock1", type:"function",
              function:{name:$n, arguments:({command:$a,path:$a,pattern:$a,name:$a}|tojson)}}]},
            finish_reason:"tool_calls"}],
          usage:{prompt_tokens:10, completion_tokens:5, prompt_tokens_details:{cached_tokens:0}}}'
      else
        jq -n --arg n "${_tname}" --arg a "${_targs}" '{
          content:[
            {type:"text",text:("Calling tool " + $n)},
            {type:"tool_use",id:"toolu_mock1",name:$n,
             input:{command:$a,path:$a,pattern:$a,name:$a}}],
          stop_reason:"tool_use"}'
      fi ;;
    *)
      if [ "${HARSH_PROVIDER}" = openai ]; then
        jq -n --arg t "[mock] You said: ${_last}" '{
          id:"chatcmpl_mock1", model:"mock-openai",
          choices:[{message:{role:"assistant", content:$t, tool_calls:null}, finish_reason:"stop"}],
          usage:{prompt_tokens:10, completion_tokens:5, prompt_tokens_details:{cached_tokens:0}}}'
      else
        jq -n --arg t "[mock] You said: ${_last}" '{content:[{type:"text",text:$t}],stop_reason:"end_turn",
          model:"mock-model", id:"msg_mock1", role:"assistant", type:"message",
          usage:{input_tokens:10, output_tokens:5, cache_read_input_tokens:0, cache_creation_input_tokens:0}}'
      fi ;;
  esac
}

# Build the wire request from harsh's canonical (Anthropic-shaped) assembled
# messages + tool schemas, in the configured provider's format. The `request`
# command shells back to `build-request` so it always matches what `step` sends.
build_request() {
  case "${HARSH_PROVIDER}" in
    openai) build_request_openai "$1" "$2" ;;
    *)      build_request_anthropic "$1" "$2" ;;
  esac
}

# Anthropic Messages API. With HARSH_CACHE on (default), inserts cache_control
# breakpoints so the model bills the repeated prefix at the cache-read rate
# (~0.1x) on later turns instead of full price every call. Render order is
# tools->system->messages, so one breakpoint on the system block covers
# tools+system (the large stable prefix); a second on the final message caches
# the conversation so far. Without it, an N-step agentic turn re-bills the whole
# prefix N times.
build_request_anthropic() {
  _bmsgs=$1; _btools=$2
  _bcache=true; case "${HARSH_CACHE:-1}" in 0|no|off|'') _bcache=false ;; esac
  jq -n --arg model "${HARSH_MODEL}" --argjson max "${HARSH_MAX_TOKENS}" \
        --arg sys "${HARSH_SYSTEM_PROMPT}" --argjson tools "${_btools}" \
        --argjson msgs "${_bmsgs}" --argjson cache "${_bcache}" '
    def bp: {cache_control:{type:"ephemeral"}};
    {
      model: $model,
      max_tokens: $max,
      system: (if $cache then [{type:"text", text:$sys} + bp] else $sys end),
      tools: $tools,
      messages: (if ($cache and ($msgs|length>0)
                     and (($msgs[-1].content|type)=="array")
                     and (($msgs[-1].content|length)>0))
                 then ($msgs | .[-1].content[-1] += bp)
                 else $msgs end)
    }'
}

# OpenAI Chat Completions. Translates the canonical messages into OpenAI's shape:
# system prompt as a leading system message; each assistant tool_use becomes a
# tool_calls entry; each tool_result becomes a separate {role:"tool"} message
# linked by tool_call_id; tools wrap as {type:"function", function:{...}}. OpenAI
# caches prefixes automatically, so HARSH_CACHE does not apply here.
build_request_openai() {
  _bmsgs=$1; _btools=$2
  jq -n --arg model "${HARSH_MODEL}" --argjson max "${HARSH_MAX_TOKENS}" \
        --arg sys "${HARSH_SYSTEM_PROMPT}" --argjson tools "${_btools}" \
        --argjson msgs "${_bmsgs}" '
    def oa_msgs:
      reduce .[] as $m ([];
        if $m.role == "assistant" then
          . + [ ( {role:"assistant"}
                  + ( ($m.content | map(select(.type=="text").text) | join("")) as $t
                      | if ($t|length)>0 then {content:$t} else {content:null} end )
                  + ( ($m.content | map(select(.type=="tool_use")
                          | {id:.id, type:"function",
                             function:{name:.name, arguments:(.input|tojson)}})) as $c
                      | if ($c|length)>0 then {tool_calls:$c} else {} end ) ) ]
        elif ($m.content | any(.type=="tool_result")) then
          . + ($m.content | map(
                if .type=="tool_result"
                then {role:"tool", tool_call_id:.tool_use_id,
                      content:(.content | if type=="string" then . else tojson end)}
                elif .type=="text" then {role:"user", content:.text}
                else empty end))
        else
          . + [ {role:"user", content: ($m.content | map(select(.type=="text").text) | join(""))} ]
        end);
    ( {model:$model, max_completion_tokens:$max,
       messages: ([{role:"system", content:$sys}] + ($msgs | oa_msgs))}
      + ( ($tools | map({type:"function",
                         function:{name:.name, description:.description, parameters:.input_schema}})) as $ot
          | if ($ot|length)>0 then {tools:$ot} else {} end) )'
}

# Normalize a provider response into the canonical Anthropic shape
# ({content:[blocks], stop_reason, usage, ...}) that cmd_step consumes, reading
# the raw response on stdin. Anthropic is already canonical (pass through); for
# OpenAI, map choices[0].message -> text/tool_use blocks and finish_reason ->
# stop_reason. Error bodies (no choices) pass through so the error path still
# finds .error.message.
normalize_response() {
  case "${HARSH_PROVIDER}" in
    openai)
      jq -c 'if has("choices") then
          (.choices[0]) as $c |
          { content:
              ( ( if (($c.message.content // "") | length) > 0
                  then [{type:"text", text:$c.message.content}] else [] end )
                + ( ($c.message.tool_calls // []) | map(
                      {type:"tool_use", id:.id, name:.function.name,
                       input:(.function.arguments | (try fromjson catch {}))}) ) ),
            stop_reason: ( $c.finish_reason
                           | if .=="tool_calls" then "tool_use"
                             elif .=="stop" then "end_turn"
                             elif .=="length" then "max_tokens"
                             else (. // "end_turn") end ),
            model: .model, id: .id,
            usage: ( (.usage // {}) | {
                       input_tokens: (.prompt_tokens // 0),
                       output_tokens: (.completion_tokens // 0),
                       cache_read_input_tokens: (.prompt_tokens_details.cached_tokens // 0),
                       cache_creation_input_tokens: 0 } ) }
        else . end' ;;
    *) cat ;;
  esac
}

# Run one tool_use block end to end: PreToolUse gate, execute the tool (with a
# private fd-3 display channel), PostToolUse feedback, then store and render the
# tool_result. Called once per block inside cmd_step's `while read` subshell.
#   do_tool_call SESSION_DIR TOOL_USE_JSON
do_tool_call() {
  _d=$1; _t=$2
  _id=$(jqv "${_t}" -r '.id'); _name=$(jqv "${_t}" -r '.name'); _input=$(jqv "${_t}" -c '.input')
  # PreToolUse — a hook may deny the call (exit 2); its reason is fed back to the
  # model as the (error) tool_result, and the tool is not run.
  _prepay=$(jq -nc --arg e PreToolUse --arg s "${_d}" --arg n "${_name}" --argjson in "${_input}" \
            '{event:$e,session_dir:$s,tool_name:$n,tool_input:$in}')
  _disp=""
  if _reason=$(run_hooks PreToolUse "${_prepay}" "${_name}"); then
    # fd 3 is a display side-channel: a tool can write rich, human-only output
    # there (e.g. edit's colored diff) that we show the user but never feed back
    # to the model. Captured to a temp file, separate from stdout (the
    # model-facing tool_result).
    _disp=$(mktemp 2>/dev/null || echo "/tmp/harsh_disp.$$")
    _out=$(printf '%s' "${_input}" | sh "${HARSH_TOOLS_DIR}/tool.sh" call "${_name}" 2>&1 3>"${_disp}"); _rc=$?
    _err=true; [ "${_rc}" -eq 0 ] && _err=false
    # PostToolUse — feedback (if any) is appended to the tool output.
    _postpay=$(jq -nc --arg e PostToolUse --arg s "${_d}" --arg n "${_name}" \
              --argjson in "${_input}" --arg o "${_out}" --argjson er "${_err}" \
              '{event:$e,session_dir:$s,tool_name:$n,tool_input:$in,tool_output:$o,is_error:$er}')
    _fb=$(run_hooks PostToolUse "${_postpay}" "${_name}") || true
    [ -n "${_fb}" ] && _out="${_out}
[hook] ${_fb}"
  else
    say "${C_TOOL}⛔ ${_name} blocked by hook:${C_RST} ${_reason}"
    _out="Tool call blocked by hook: ${_reason}"; _err=true
  fi
  _block=$(jq -nc --arg id "${_id}" --arg out "${_out}" --argjson e "${_err}" \
    '{type:"tool_result", tool_use_id:$id, content:$out, is_error:$e}')
  _rseq=$(next_seq "${_d}")   # the #handle for `verbose`, captured before the write
  add_entry "${_d}" user tool_result "${_name}" "${_block}"
  [ -n "${HARSH_QUIET:-}" ] || render_tool_result "${_rseq}" "${_name}" "${_input}" "${_out}" "${_err}"
  # Show the fd-3 display channel to the user only (never the model's context).
  if [ -z "${HARSH_QUIET:-}" ] && [ -n "${_disp}" ] && [ -s "${_disp}" ]; then
    sed 's/^/  /' "${_disp}"
  fi
  [ -n "${_disp}" ] && rm -f "${_disp}"
}

# One model turn. Appends assistant blocks; if the model asked for tools, runs
# them (do_tool_call) and appends tool_result blocks.
# returns: 0 = finished, 2 = tool_use (caller should continue), 1 = error,
#          3 = truncated at max_tokens (caller may continue the reply).
cmd_step() {
  _dir=$(session_dir "$1")
  [ -d "${_dir}" ] || die "no such session: ${_dir}"
  _msgs=$(cmd_assemble "$1")
  _tools=$(sh "${HARSH_TOOLS_DIR}/tool.sh" schemas 2>/dev/null); [ -n "${_tools}" ] || _tools='[]'
  _req=$(build_request "${_msgs}" "${_tools}")
  _resp=$(call_api "${_req}" "${_dir}") || return 1
  # Fold the provider response into the canonical shape the rest of this
  # function expects (content blocks + stop_reason + usage meta).
  _resp=$(printf '%s' "${_resp}" | normalize_response)

  if [ "$(jqv "${_resp}" -r 'has("content")')" != "true" ]; then
    warn "[error] $(jqv "${_resp}" -r '.error.message // .message // "unknown API error"')"
    return 1
  fi

  # Per-turn response metadata (everything the API returned except the content
  # blocks themselves): usage/token counts, stop_reason, model, id, etc. We
  # attach it to this turn's first assistant block so it's preserved in the
  # session record; cmd_assemble drops it when building the API request.
  _meta=$(jqv "${_resp}" -c 'del(.content, .role, .type)')

  # Record each assistant content block; the turn meta rides the first one. Tool
  # calls render with their result (do_tool_call) below; here we show prose only.
  _i=0
  jqv "${_resp}" -c '.content[]' | while IFS= read -r _block; do
    _btype=$(jqv "${_block}" -r '.type'); _bname=$(jqv "${_block}" -r '.name // ""')
    if [ "${_i}" -eq 0 ]; then _m=${_meta}; else _m=""; fi
    add_entry "${_dir}" assistant "${_btype}" "${_bname}" "${_block}" "${_m}"
    # Streamed text was already printed live (call_api's delta path) — skip the
    # replay; everything else renders as usual.
    if [ "${_btype}" = text ] && [ -z "${HARSH_QUIET:-}" ] && ! stream_on; then
      render_assistant "$(jqv "${_block}" -r '.text')"
    fi
    _i=$((_i + 1))
  done

  _stop=$(jqv "${_resp}" -r '.stop_reason // ""')
  if [ "${_stop}" = tool_use ]; then
    jqv "${_resp}" -c '.content[] | select(.type=="tool_use")' | while IFS= read -r _tu; do
      do_tool_call "${_dir}" "${_tu}"
    done
    return 2
  fi
  # Hitting the output cap mid-reply must not read as a clean finish: the reply
  # is incomplete. Signal the caller, which re-steps — the conversation then
  # ends on an assistant message, so the model continues where it was cut off.
  if [ "${_stop}" = max_tokens ]; then
    warn "[warn] reply truncated at HARSH_MAX_TOKENS=${HARSH_MAX_TOKENS} — asking the model to continue"
    return 3
  fi
  return 0
}

# Run the agent loop to completion (or HARSH_MAX_TURNS).
cmd_run() {
  _sess=$1
  _dir=$(session_dir "${_sess}")
  _turns=0; _stops=0; _truncs=0
  while [ "${_turns}" -lt "${HARSH_MAX_TURNS}" ]; do
    # Auto-compaction: without it the request grows without bound until the
    # provider rejects it. Checked before each step using the previous turn's
    # actual usage numbers (not an estimate). The engine only owns the trigger;
    # the summarization policy is the drop-in `compact` command, resolved like
    # any other — replace the file to change the policy, remove it to opt out.
    # A failed (or missing) compaction is non-fatal: worst case the next call
    # fails with the provider's own error.
    if [ "${HARSH_COMPACT_AT}" -gt 0 ]; then
      _ctx=$(last_context_tokens "${_dir}")
      if [ "${_ctx:-0}" -gt "${HARSH_COMPACT_AT}" ]; then
        if _cp=$(resolve_command cli compact); then
          say "${C_DIM}↻ compacting context (~${_ctx} tokens > HARSH_COMPACT_AT=${HARSH_COMPACT_AT})${C_RST}"
          sh "${_cp}" "${_sess}" || warn "[warn] compaction failed; continuing with full context"
        else
          warn "[warn] context is ~${_ctx} tokens (> HARSH_COMPACT_AT) but no compact command is installed"
        fi
      fi
    fi
    cmd_step "${_sess}"; _rc=$?
    _turns=$((_turns + 1))
    case ${_rc} in
      0)
        # Stop — a hook may force another turn (exit 2) by injecting a message,
        # up to a small cap so it can't loop forever.
        if [ "${_stops}" -lt 3 ]; then
          _sp=$(jq -nc --arg e Stop --arg s "${_dir}" '{event:$e,session_dir:$s}')
          if _reason=$(run_hooks Stop "${_sp}"); then
            return 0
          fi
          _stops=$((_stops + 1))
          say "${C_DIM}↻ continuing (Stop hook):${C_RST} ${_reason}"
          add_entry "${_dir}" user text "" "$(jq -nc --arg t "${_reason}" '{type:"text",text:$t}')"
          continue
        fi
        return 0 ;;
      2) continue ;;
      3)
        # Truncated reply: re-step so the model continues it, but bounded — a
        # reply that can't finish in a few extensions needs a bigger
        # HARSH_MAX_TOKENS, not an unbounded loop.
        if [ "${_truncs}" -lt 3 ]; then
          _truncs=$((_truncs + 1))
          continue
        fi
        warn "[error] reply still truncated after ${_truncs} continuations — raise HARSH_MAX_TOKENS"
        return 1 ;;
      *) return 1 ;;
    esac
  done
  say "[harsh] reached max turns (${HARSH_MAX_TURNS})"
}

# Send a user message then run to completion.
cmd_ask() {
  _sess=$1; shift
  cmd_send "${_sess}" "$*" && cmd_run "${_sess}"
}

# Invoke a skill: load its instructions via the Skills tool, inject as a user
# message, and run. Backs slash commands in the REPL.
cmd_skill() {
  _sess=$1; _name=$2; shift 2 2>/dev/null || shift $#
  _args=$*
  _input=$(jq -nc --arg n "${_name}" --arg a "${_args}" '{name:$n,args:$a}')
  if ! _content=$(printf '%s' "${_input}" | sh "${HARSH_TOOLS_DIR}/tool.sh" call skills); then
    say "skill not found: ${_name}"
    return 1
  fi
  _msg=$(printf 'Please follow the "%s" skill below. Arguments: %s\n\n%s' "${_name}" "${_args}" "${_content}")
  cmd_send "${_sess}" "${_msg}" && cmd_run "${_sess}"
}

repl_help() {
  cat <<'EOF'
REPL:
  <text>           send a message to the agent and run
  /SKILL [args]    invoke a skill (e.g. /commit, /review)
  /verbose         toggle full tool output;  /verbose #SEQ  expand one entry
  /new             start a fresh session  (/sessions to list, /resume <ID> to switch)
  /help            this help;  /quit  exit (or Ctrl-D)

  Ctrl-C           cancel the current line (Ctrl-D or /quit to exit)
  (paste)          a multi-line paste is sent as a single prompt
  ↑/↓              recall earlier input — only in HARSH_RLWRAP=1 mode (Ctrl-R searches)

Commands (type as /NAME — SESSION is filled in automatically):
EOF
  list_commands repl | sort | sed 's/^/  \//'
}

# Bracketed-paste markers. Terminals in bracketed-paste mode wrap a paste in
# ESC[200~ … ESC[201~, so a multi-line paste arrives as several `read` lines with
# the start marker on the first and the end marker on the last. We use these to
# stitch a paste back into ONE prompt (see read_prompt). The bytes are built once.
_PASTE_BEG=$(printf '\033[200~'); _PASTE_END=$(printf '\033[201~')
_ESC=$(printf '\033')

# strip_nav — remove bare cursor/navigation escape sequences from a typed line.
# Without readline (the default native loop), pressing ↑/↓/←/→ or Home/End emits
# CSI sequences (ESC[A, ESC[1~, …) that `read` would otherwise capture as literal
# junk in the message. We can't turn them into history, but we can keep them from
# corrupting input. Sets $_line. Only applied to typed lines, never to pastes
# (a pasted snippet may legitimately contain escapes).
strip_nav() {
  case "${_line}" in
    *"${_ESC}["*)
      # Drop ESC '[' then any parameter/intermediate bytes then a final letter
      # or '~'. Repeat until no such sequence remains.
      while :; do
        case "${_line}" in
          *"${_ESC}["*) ;;
          *) break ;;
        esac
        _pre=${_line%%"${_ESC}["*}
        _post=${_line#*"${_ESC}["}
        # Trim leading params (digits, ';') and one final byte (letter or '~').
        while :; do
          case "${_post}" in
            [0-9\;]*) _post=${_post#?} ;;
            *) break ;;
          esac
        done
        _post=${_post#?}   # drop the final command byte
        _line="${_pre}${_post}"
      done ;;
  esac
}

# read_prompt — read one logical line of REPL input into $_line. A normal line is
# returned as-is. A bracketed paste (multi-line) is accumulated across reads until
# its end marker and returned as a single newline-joined string, so pasting many
# lines yields ONE prompt instead of one-per-line. Returns non-zero at EOF.
read_prompt() {
  IFS= read -r _line || return 1
  case "${_line}" in
    "${_PASTE_BEG}"*)
      # Strip the start marker; keep reading until the end marker appears.
      _line=${_line#"${_PASTE_BEG}"}
      while :; do
        case "${_line}" in
          *"${_PASTE_END}"*) _line=${_line%"${_PASTE_END}"*}; break ;;
        esac
        IFS= read -r _more || break
        _line="${_line}
${_more}"
      done ;;
    *)
      # Typed line: scrub stray cursor-key / nav escapes so they don't pollute
      # the message. (Pastes are handled above and left intact.)
      strip_nav ;;
  esac
  return 0
}

# Default interactive mode: a dependency-free, line-based REPL — it needs
# nothing beyond the core.
#
# Input handling — why the native loop is the default:
#   The native loop (read_prompt, below) puts the terminal in bracketed-paste
#   mode (ESC[?2004h) and stitches a multi-line paste back into ONE prompt. This
#   is the behaviour people expect when they paste a snippet.
#
#   rlwrap would give us ↑/↓ history and richer line editing "for free", but it
#   CANNOT preserve a multi-line paste: rlwrap's readline accepts each pasted
#   newline as a separate line, so a paste arrives as one-prompt-per-line — and
#   its own man page warns that bracketed paste "will confuse rlwrap". No flag
#   combination (-m, --multi-line-ext, enable-bracketed-paste) fixes this in
#   rlwrap 0.48 / readline 8.3. Correct paste and rlwrap are mutually exclusive,
#   so we default to correct paste.
#
#   rlwrap remains available as an explicit opt-in (HARSH_RLWRAP=1) for people
#   who want history/editing and don't paste multi-line. HARSH_RLWRAP=1 both
#   selects the rlwrap path AND guards against re-exec re-entry.
cmd_repl() {
  if [ -t 0 ] && [ "${HARSH_RLWRAP:-}" = 1 ] && [ "${HARSH_NO_RLWRAP:-}" != 1 ] \
     && command -v rlwrap >/dev/null 2>&1; then
    _hist="${HARSH_LOG_DIR:-${SELF_DIR}/logs}/repl_history"
    mkdir -p "$(dirname "${_hist}")" 2>/dev/null || true
    # HARSH_CONFIG and the dir vars are already exported by load_config; carry the
    # quiet flag too so the re-exec'd REPL behaves identically. HARSH_RLWRAP=2
    # marks "already under rlwrap" so the re-exec'd child takes the native loop.
    export HARSH_QUIET="${HARSH_QUIET:-}" HARSH_RLWRAP=2
    exec rlwrap -C harsh -H "${_hist}" -s 5000 \
      sh "${SELF_DIR}/harsh.sh" repl "$@"
  fi
  if [ "${1:-}" != "" ]; then
    _sess=$1
    _dir=$(session_dir "${_sess}")
    [ -d "${_dir}" ] || _dir=$(cmd_init "${_sess}")
    _sess=${_dir}
  else
    _dir=$(cmd_init); _sess=${_dir}
  fi
  _tty=0; [ -t 0 ] && _tty=1
  if [ "${_tty}" = 1 ]; then
    printf '%s╶─ harsh %s · REPL · %s ─╴%s\n' "${C_BAR}" "${HARSH_VERSION}" "${_sess}" "${C_RST}" >&2
    if [ -n "${HARSH_RLWRAP:-}" ]; then
      printf '%sType a message and press Enter. ↑/↓ history, /help for commands, /quit to exit.%s\n' "${C_DIM}" "${C_RST}" >&2
      printf '%s(rlwrap mode: multi-line pastes arrive one line per prompt — unset HARSH_RLWRAP for paste support)%s\n' "${C_DIM}" "${C_RST}" >&2
    else
      printf '%sType a message and press Enter. /help for commands, /quit to exit.%s\n' "${C_DIM}" "${C_RST}" >&2
      command -v rlwrap >/dev/null 2>&1 && printf '%s(set HARSH_RLWRAP=1 for ↑/↓ history and line editing — note: disables multi-line paste)%s\n' "${C_DIM}" "${C_RST}" >&2
    fi
    if [ -z "${HARSH_API_KEY}" ] && [ -z "${HARSH_MOCK:-}" ]; then
      printf '! No API key set — the agent cannot respond. Export ANTHROPIC_API_KEY,\n' >&2
      printf '! or set HARSH_MOCK=1 for an offline mock model.\n' >&2
    fi
    # Ask the terminal to bracket pastes so a multi-line paste reads as ONE
    # prompt (handled in read_prompt). Disabled again only when we exit — NOT on
    # Ctrl-C, which must cancel the current line and keep the REPL running.
    # Skip under rlwrap (HARSH_RLWRAP=2): rlwrap owns the TTY and bracketed paste
    # "confuses rlwrap" (its words), so emitting it there only causes glitches.
    if [ "${HARSH_RLWRAP:-}" != 2 ]; then
      printf '\033[?2004h' >&2
      # Clean up the terminal on real exit / kill only.
      trap 'printf "\033[?2004l" >&2' EXIT TERM
    fi
    # Ctrl-C cancels the line in progress, like a normal shell: the tty discards
    # the partial line and this handler acknowledges the interrupt. The
    # interrupted read resumes in place (it does NOT loop back to the prompt), so
    # the handler also redraws the "harsh>" prompt — otherwise the user is left on
    # a bare line. We do NOT exit and do NOT swallow the next line.
    trap 'printf "%s^C — interrupted (Ctrl-D or /quit to exit)%s\n%sharsh>%s " "${C_DIM}" "${C_RST}" "${C_USER}" "${C_RST}" >&2' INT
  fi
  while :; do
    [ "${_tty}" = 1 ] && printf '%sharsh>%s ' "${C_USER}" "${C_RST}" >&2
    read_prompt || break
    case "${_line}" in
      '') continue ;;
      /quit|/exit|/q) break ;;
      /help)    repl_help >&2 ;;
      /verbose|/v)
        # No arg: toggle global verbose (every tool result prints in full).
        if [ -n "${HARSH_VERBOSE:-}" ]; then
          HARSH_VERBOSE=; printf '%s[verbose off]%s\n' "${C_DIM}" "${C_RST}" >&2
        else
          HARSH_VERBOSE=1; printf '%s[verbose on]%s\n' "${C_DIM}" "${C_RST}" >&2
        fi ;;
      '/verbose '*|'/v '*)
        # With a #SEQ arg: expand that one entry without changing the mode.
        run_command verbose "${_sess}" "${_line#* }" ;;
      # /session, /sessions, and /resume are ordinary commands now (see
      # commands/session.sh, commands/sessions.sh, commands/repl/resume.sh) —
      # they resolve through the /NAME path below. /resume requests a switch by
      # writing the target to $HARSH_SESSION_OUT, which the loop honors there.
      /new)
        _dir=$(cmd_init); _sess=${_dir}
        [ "${_tty}" = 1 ] && printf '[new session: %s]\n' "${_sess}" >&2 ;;
      /*)
        # Any commands/ verb is reachable as /NAME; the current session is filled
        # in for session-scoped ones. Otherwise fall back to a skill of that name.
        _name=${_line#/}; _rest=""
        case "${_name}" in *' '*) _rest=${_name#* }; _name=${_name%% *} ;; esac
        if _p=$(resolve_command repl "${_name}"); then
          # Two channels let a command interact with the loop's current session:
          # HARSH_CURRENT_SESSION (read) names it; a command that writes a target
          # to the HARSH_SESSION_OUT file requests a switch (see resume).
          _sout=$(mktemp 2>/dev/null || echo "/tmp/harsh_sout.$$"); : > "${_sout}"
          if command_wants_session "${_p}"; then
            # shellcheck disable=SC2086  # split rest into positional args
            HARSH_CURRENT_SESSION="${_sess}" HARSH_SESSION_OUT="${_sout}" sh "${_p}" "${_sess}" ${_rest}
          else
            # shellcheck disable=SC2086
            HARSH_CURRENT_SESSION="${_sess}" HARSH_SESSION_OUT="${_sout}" sh "${_p}" ${_rest}
          fi
          if [ -s "${_sout}" ]; then
            _ndir=$(session_dir "$(cat "${_sout}")")
            { [ -d "${_ndir}" ] && [ -f "${_ndir}/manifest.csv" ]; } && { _dir=${_ndir}; _sess=${_dir}; }
          fi
          rm -f "${_sout}"
        elif resolve_command cli "${_name}" >/dev/null 2>&1; then
          printf '%s/%s is a CLI-only command — run: harsh.sh %s …%s\n' \
            "${C_DIM}" "${_name}" "${_name}" "${C_RST}" >&2
        else
          cmd_skill "${_sess}" "${_name}" "${_rest}"
        fi ;;
      *)
        # No prompt echo: the user just typed it at the "harsh>" line directly
        # above, so repeating it only adds noise. A blank line sets the reply
        # off; once the prompt is recorded, a dim "working…" acknowledges the
        # turn has started — the API call blocks, so this is the only feedback
        # until the reply lands.
        [ "${_tty}" = 1 ] && printf '\n' >&2
        if cmd_send "${_sess}" "${_line}"; then
          [ "${_tty}" = 1 ] && printf '%s%s working…%s\n' "${C_DIM}" "${GUTTER}" "${C_RST}" >&2
          cmd_run "${_sess}"
        fi ;;
    esac
  done
  if [ "${_tty}" = 1 ]; then
    trap - INT
    if [ "${HARSH_RLWRAP:-}" != 2 ]; then
      printf '\033[?2004l' >&2        # leave bracketed-paste mode
      trap - EXIT TERM
    fi
    printf '%s%s harsh · bye%s\n' "${C_DIM}" "${GUTTER}" "${C_RST}" >&2
  fi
  return 0
}

usage() {
  cat <<EOF
harsh ${HARSH_VERSION} — a portable shell agent harness

Usage: harsh.sh [-c CONFIG] [-q] [COMMAND [ARGS...]]

With no command, harsh.sh starts an interactive REPL.

Interactive:
  repl [SESSION]         Line-based REPL (default when no command is given).

Engine primitives (built in):
  init|new [NAME]        Create a session; prints its directory.
  send SESSION TEXT...   Append a user message.
  step SESSION           Run one model turn (executes tools if requested).
  run SESSION            Run the agent loop to completion.
  ask SESSION TEXT...    send + run in one go.
  skill SESSION NAME [A] Load a skill and run it.
  assemble SESSION       Print the Messages-API messages[] array (the live view).
  remanifest SESSION     Rewrite the live view from a spec on stdin; entry
                         files never move and the old view is retired to
                         manifest-<ts>.csv (powers compaction).
  path SESSION           Print the resolved session directory.

Commands (extensible — drop a NAME.sh into \$HARSH_COMMANDS_DIR):
EOF
  list_commands | sort | sed 's/^/  /'
  cat <<EOF

Environment / config (see harsh.conf):
  HARSH_PROVIDER (anthropic | openai; default anthropic),
  HARSH_API_KEY / ANTHROPIC_API_KEY / OPENAI_API_KEY, HARSH_MODEL,
  HARSH_MAX_TOKENS, HARSH_SYSTEM_PROMPT, HARSH_MAX_TURNS,
  HARSH_COMPACT_AT (auto-compaction threshold in tokens; 0 disables),
  HARSH_RETRIES / HARSH_RETRY_DELAY (transient API failure backoff),
  HARSH_STREAM=1 (stream replies live; anthropic only),
  HARSH_MOCK (offline test mode),
  HARSH_CACHE (Anthropic prompt caching, on by default; 0 to disable),
  HARSH_VERBOSE (print full tool output instead of a collapsed summary).
  Directories (set in harsh.conf, absolute): HARSH_TOOLS_DIR, HARSH_SKILLS_DIR,
  HARSH_HOOKS_DIR, HARSH_COMMANDS_DIR, HARSH_SESSIONS_DIR, HARSH_LOG_DIR.
EOF
}

# ---------------------------------------------------------------------------
# entry
# ---------------------------------------------------------------------------
while [ $# -gt 0 ]; do
  case "$1" in
    -c) HARSH_CONFIG=$2; shift 2 ;;
    -q|--quiet) HARSH_QUIET=1; shift ;;
    --) shift; break ;;
    -*) die "unknown option: $1" ;;
    *)  break ;;
  esac
done
load_config

_cmd=${1:-repl}; [ $# -gt 0 ] && shift
case "${_cmd}" in
  # --- engine primitives (in-process; reserved, never shadowed) -------------
  repl)     cmd_repl "$@" ;;
  init|new) cmd_init "$@" ;;
  send)     cmd_send "$@" ;;
  step)     cmd_step "$@" ;;
  run)      cmd_run "$@" ;;
  ask)      cmd_ask "$@" ;;
  skill)    cmd_skill "$@" ;;
  assemble) cmd_assemble "$@" ;;
  remanifest) cmd_remanifest "$@" ;;
  path)     cmd_path "$@" ;;
  # Run hooks for EVENT with PAYLOAD_JSON (and optional per-TOOL scope):
  # context on stdout, exit 2 = blocked. Lets drop-in commands (e.g. compact)
  # participate in the hook system through the engine's one implementation.
  run-hooks) run_hooks "$@" ;;
  # Print the wire request a step would send (used by commands/request.sh so the
  # provider-specific builder lives in exactly one place).
  build-request)
    _m=$(cmd_assemble "$1")
    _t=$(sh "${HARSH_TOOLS_DIR}/tool.sh" schemas 2>/dev/null); [ -n "${_t}" ] || _t='[]'
    build_request "${_m}" "${_t}" ;;
  # Fold raw Anthropic SSE (on stdin) into a canonical response. Internal —
  # call_api's streaming path uses the same transform; exposed for tests.
  stream-assemble)
    sed -n 's/^data: //p' | stream_assemble ;;
  # --- meta -----------------------------------------------------------------
  commands) list_commands "$@" | sort ;;
  help|-h|--help) usage ;;
  # --- everything else: an extensible command from $HARSH_COMMANDS_DIR ------
  *)
    if _p=$(resolve_command cli "${_cmd}"); then
      exec sh "${_p}" "$@"
    fi
    die "unknown command: ${_cmd} (try: harsh.sh help)" ;;
esac