#!/usr/bin/env bash
#
# claude_cli_runner.sh
# =============================================================================
# Production-ready wrapper around the Claude Code CLI (`claude -p`) for
# non-interactive / scripted use (CI, cron, pipelines, agent orchestration).
#
# DESIGN NOTES:
#   1. The prompt text is NEVER hardcoded in this script. It must be
#      supplied at call time via the -P/--prompt flag (or the PROMPT env
#      var). This keeps the script reusable as a generic template.
#
#   2. AGENT-SAFE OUTPUT BY DEFAULT. If another LLM agent (or any caller
#      that dumps stdout straight into its own context window) invokes
#      this script, a large Claude response landing on stdout would
#      flood that caller's context. To prevent that, this script:
#        - ALWAYS writes the full raw result to a file on disk
#          (auto-generated under a temp dir if -o/--output isn't given).
#        - ALWAYS writes the jq-validated `structured_output` (when a
#          --json-schema was used) to its own file.
#        - By default prints ONLY a short, fixed-shape status summary to
#          stdout: file paths, byte sizes, turn count, cost, session id,
#          success/failure. It does NOT print the response body.
#      Full content is only echoed to stdout if the caller explicitly
#      opts in with --print-full (intended for human/interactive use,
#      not for agent-to-agent invocation).
#
# USAGE:
#   ./claude_cli_runner.sh -P "<prompt text>" [OPTIONS]
#   PROMPT="<prompt text>" ./claude_cli_runner.sh [OPTIONS]
#
# REQUIRED:
#   -P, --prompt <text>       The prompt to send to Claude. Required unless
#                              the PROMPT environment variable is already set.
#
# OPTIONAL:
#   -f, --file <path>         A source file to append to the prompt context
#                              (e.g. "auth.py"). If provided, the script
#                              appends the file path to the prompt text and
#                              validates the file exists before running.
#   -d, --workdir <path>      Working directory Claude should operate in.
#                              Defaults to the current directory ($PWD).
#   -s, --schema <json|path>  A JSON schema (inline string OR path to a
#                              .json file) to pass via --json-schema.
#                              Forces --output-format json if --format was
#                              not explicitly given. Triggers structured
#                              output extraction/validation via jq.
#   -o, --output <path>       File to write the raw Claude response to.
#                              If omitted, an auto-named file is created
#                              under ${TMPDIR:-/tmp}/claude-cli-runner/.
#   --structured-output <p>   File to write the extracted, jq-validated
#                              `structured_output` JSON to (only relevant
#                              when --schema is set). Defaults to
#                              "<output>.structured.json".
#   -t, --max-turns <n>       Optional hard cap for agentic turns.
#                              Default is unlimited; omit unless a hard
#                              cap was explicitly requested.
#   -m, --model <name>        Model alias/name (e.g. sonnet, opus, haiku).
#                              Default: claude-sonnet-5.
#   -a, --allowed-tools <..>  Comma/space-separated tool pre-approval list, e.g.
#                              "Read,Edit" or "Read,Bash(git *)".
#   --tools <..>              Restrict the built-in tool set available to Claude,
#                              e.g. "Read,Edit" or "" to disable tools.
#   --format <fmt>             --output-format value: text|json|stream-json.
#                              Default: text, or json automatically if
#                              --schema is supplied.
#   --bare                     Pass --bare to Claude (skip hooks/plugins/
#                              MCP discovery/CLAUDE.md). This runner never
#                              performs Claude auth bootstrap; it assumes
#                              Claude auth already exists and fails closed on
#                              auth errors.
#   --timeout <secs>           Hard wall-clock backstop for the claude
#                              invocation. When the `timeout` (or `gtimeout`)
#                              coreutils binary is available, claude is wrapped
#                              so it self-terminates after <secs> even if the
#                              parent orchestrator/process dies and orphans this
#                              runner. If neither binary is present, a bundled
#                              Node watchdog provides the same backstop. 0 disables.
#   --stream                   Use --output-format stream-json --verbose
#                              --include-partial-messages and write the JSONL
#                              event log to the output file as it is produced,
#                              so the file grows live. Lets a watching caller
#                              detect "no activity for N seconds". The final
#                              `result` event supplies the same status fields as
#                              --format json, plus a bounded `result_text`.
#                              Mutually exclusive with -s/--schema.
#   --disallowed-tools <..>    Passed through as --disallowedTools (deny rules).
#   --append-system-prompt <s> Passed through as --append-system-prompt.
#   --fallback-model <name>    Passed through as --fallback-model (comma list
#                              tried in order when the primary is unavailable).
#   --add-dir <path>           Passed through as --add-dir.
#   --permission-mode <mode>   Passed through as --permission-mode.
#   --print-full                Print the full raw response body to stdout
#                              in addition to writing it to file. Use this
#                              for interactive/human runs only — NOT when
#                              this script is invoked by another agent,
#                              since it defeats the context-window
#                              protection described above.
#   --stdout-mode <mode>        summary (default) | structured | full
#                              - summary:    only the status line (safe)
#                              - structured: status line + the validated
#                                            structured_output JSON only
#                                            (still bounded/small; requires
#                                            --schema)
#                              - full:       equivalent to --print-full
#   --dry-run                  Print the constructed claude command without
#                              executing it.
#   -h, --help                  Show this help text and exit.
#
# EXIT CODES:
#   0  success
#   1  usage / argument error
#   2  missing dependency (claude or jq not found)
#   3  claude invocation failed, or Claude JSON reported an error subtype
#      (status JSON is still emitted with output/stderr artifact paths)
#   4  jq validation/extraction of JSON/structured_output failed
#      (status JSON is still emitted when execution reached Claude)
#
# EXAMPLES:
#   # Simple one-off prompt; only a status summary hits stdout
#   ./claude_cli_runner.sh -P "Summarize the changes in the last commit"
#
#   # Extract structured data from a file with a JSON schema.
#   # Agent-safe: stdout gets a summary + file paths, not the payload.
#   ./claude_cli_runner.sh \
#     -P "Extract the main function names from" \
#     -f auth.py \
#     -s '{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}},"required":["functions"]}'
#
#   # Same, but a human wants to see the structured result immediately
#   ./claude_cli_runner.sh \
#     -P "Extract the main function names from" -f auth.py \
#     -s '{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}}}' \
#     --stdout-mode structured
#
#   # Dry run to inspect the command before it executes
#   ./claude_cli_runner.sh -P "Review this diff" --dry-run
# =============================================================================

set -euo pipefail

# ---- Defaults ---------------------------------------------------------------
PROMPT="${PROMPT:-}"
TARGET_FILE=""
WORKDIR="$PWD"
SCHEMA=""
OUTPUT_FILE=""
STRUCTURED_OUTPUT_FILE=""
MAX_TURNS=""
MODEL="claude-sonnet-5"
ALLOWED_TOOLS=""
TOOLS=""              # --tools availability restriction (not just pre-approval)
OUTPUT_FORMAT=""
USE_BARE=false
DRY_RUN=false
STDOUT_MODE="summary"   # summary | structured | full
RUNNER_TIMEOUT=""       # hard wall-clock backstop (secs) via timeout(1)
STREAM_MODE=false       # stream-json event log (enables live activity signal)
# Defense in depth: nested-agent tools must never be available to workers
# launched through this extension. Keep both names because Claude Code releases
# have exposed the capability under both names.
HARD_DISALLOWED_TOOLS="Agent,Task"
DISALLOWED_TOOLS=""     # caller-supplied additional --disallowedTools deny rules
APPEND_SYSTEM_PROMPT="" # --append-system-prompt extra standing instructions
FALLBACK_MODEL=""       # --fallback-model chain for overloaded/unavailable models
ADD_DIR=""              # --add-dir additional working directory
PERMISSION_MODE=""      # --permission-mode (e.g. plan|acceptEdits|bypassPermissions)
RESULT_TEXT_MAX=4000    # bytes of final result text surfaced in the status envelope

# ---- Helpers ------------------------------------------------------------

usage() {
  awk '/^# ===/{c++; if (c==2) exit} c==1' "$0"
  exit 1
}

log_err() {
  echo "[claude_cli_runner] ERROR: $*" >&2
}

require_dependency() {
  local bin="$1"
  if ! command -v "$bin" >/dev/null 2>&1; then
    log_err "required dependency '$bin' not found in PATH."
    exit 2
  fi
}

# ---- Argument parsing ------------------------------------------------------

while [ $# -gt 0 ]; do
  case "$1" in
    -P|--prompt)
      PROMPT="$2"; shift 2 ;;
    -f|--file)
      TARGET_FILE="$2"; shift 2 ;;
    -d|--workdir)
      WORKDIR="$2"; shift 2 ;;
    -s|--schema)
      SCHEMA="$2"; shift 2 ;;
    -o|--output)
      OUTPUT_FILE="$2"; shift 2 ;;
    --structured-output)
      STRUCTURED_OUTPUT_FILE="$2"; shift 2 ;;
    -t|--max-turns)
      MAX_TURNS="$2"; shift 2 ;;
    -m|--model)
      MODEL="$2"; shift 2 ;;
    -a|--allowed-tools)
      ALLOWED_TOOLS="$2"; shift 2 ;;
    --tools)
      TOOLS="$2"; shift 2 ;;
    --format)
      OUTPUT_FORMAT="$2"; shift 2 ;;
    --bare)
      USE_BARE=true; shift ;;
    --timeout)
      RUNNER_TIMEOUT="$2"; shift 2 ;;
    --stream)
      STREAM_MODE=true; shift ;;
    --disallowed-tools|--disallowedTools)
      DISALLOWED_TOOLS="$2"; shift 2 ;;
    --append-system-prompt)
      APPEND_SYSTEM_PROMPT="$2"; shift 2 ;;
    --fallback-model)
      FALLBACK_MODEL="$2"; shift 2 ;;
    --add-dir)
      ADD_DIR="$2"; shift 2 ;;
    --permission-mode)
      PERMISSION_MODE="$2"; shift 2 ;;
    --print-full)
      STDOUT_MODE="full"; shift ;;
    --stdout-mode)
      STDOUT_MODE="$2"; shift 2 ;;
    --dry-run)
      DRY_RUN=true; shift ;;
    -h|--help)
      usage ;;
    *)
      log_err "unknown argument: $1"
      usage ;;
  esac
done

# ---- Validation -------------------------------------------------------------

require_dependency claude
require_dependency jq

if [ -z "$PROMPT" ]; then
  log_err "no prompt supplied. Use -P/--prompt \"<text>\" or set the PROMPT env var."
  usage
fi

case "$STDOUT_MODE" in
  summary|structured|full) ;;
  *) log_err "invalid --stdout-mode: $STDOUT_MODE (expected summary|structured|full)"; exit 1 ;;
esac

# Resolve an optional hard wall-clock backstop. This is defense-in-depth: even
# if the orchestrating process dies and orphans this runner, `timeout` (or the
# bundled Node watchdog on systems without timeout/gtimeout) still terminates a
# wedged claude invocation.
TIMEOUT_PREFIX=()
USE_PORTABLE_WATCHDOG=false
if [ -n "$RUNNER_TIMEOUT" ]; then
  case "$RUNNER_TIMEOUT" in
    ''|*[!0-9]*) log_err "invalid --timeout: '$RUNNER_TIMEOUT' (expected whole seconds)"; exit 1 ;;
  esac
  if [ "$RUNNER_TIMEOUT" -gt 0 ]; then
    TIMEOUT_BIN=""
    if [ "${CLAUDE_RUNNER_FORCE_PORTABLE_WATCHDOG:-false}" != "true" ] && command -v timeout >/dev/null 2>&1; then TIMEOUT_BIN="timeout";
    elif [ "${CLAUDE_RUNNER_FORCE_PORTABLE_WATCHDOG:-false}" != "true" ] && command -v gtimeout >/dev/null 2>&1; then TIMEOUT_BIN="gtimeout"; fi
    if [ -n "$TIMEOUT_BIN" ]; then
      # SIGTERM at the deadline, escalate to SIGKILL 10s later if claude ignores it.
      TIMEOUT_PREFIX=("$TIMEOUT_BIN" --signal=TERM --kill-after=10s "${RUNNER_TIMEOUT}s")
    else
      # Stock macOS has neither GNU timeout nor gtimeout. Use the Node runtime
      # Claude Code already requires as a self-contained backstop instead of
      # silently losing orphan protection when the Pi parent exits.
      USE_PORTABLE_WATCHDOG=true
    fi
  fi
fi

if [ -n "$TARGET_FILE" ]; then
  case "$TARGET_FILE" in
    /*) RESOLVED_TARGET="$TARGET_FILE" ;;             # absolute path as given
    *)  RESOLVED_TARGET="$WORKDIR/$TARGET_FILE" ;;    # relative to workdir
  esac
  if [ ! -f "$RESOLVED_TARGET" ] && [ ! -f "$TARGET_FILE" ]; then
    log_err "target file not found: $TARGET_FILE (checked '$RESOLVED_TARGET' and '$TARGET_FILE')"
    exit 1
  fi
fi

if [ ! -d "$WORKDIR" ]; then
  log_err "workdir not found: $WORKDIR"
  exit 1
fi

if [ "$STDOUT_MODE" = "structured" ] && [ -z "$SCHEMA" ]; then
  log_err "--stdout-mode structured requires -s/--schema to be set."
  exit 1
fi

# If a schema was provided, default to JSON output unless the caller
# explicitly requested a different --format.
if [ -n "$SCHEMA" ] && [ -z "$OUTPUT_FORMAT" ]; then
  OUTPUT_FORMAT="json"
fi
OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}"

# Resolve schema: accept either an inline JSON string or a path to a file.
SCHEMA_ARG=""
if [ -n "$SCHEMA" ]; then
  SCHEMA_ARG="$SCHEMA"   # claude accepts both inline JSON and a file path
fi

# Stream mode: emit a JSON event per message/tool/token-delta so the raw output
# file grows in real time. This is what lets a caller detect "no activity for N
# seconds" — plain --output-format json is silent until the very end. The final
# `result` event carries the same summary fields the envelope reports.
if [ "$STREAM_MODE" = true ]; then
  if [ -n "$SCHEMA_ARG" ]; then
    log_err "--stream cannot be combined with -s/--schema; use --format json for structured output."
    exit 1
  fi
  OUTPUT_FORMAT="stream-json"
fi

# Build the final prompt text (append target file reference if given).
FULL_PROMPT="$PROMPT"
if [ -n "$TARGET_FILE" ]; then
  FULL_PROMPT="${PROMPT} ${TARGET_FILE}"
fi

# ---- Resolve output file paths (always write to disk; never rely on
#      stdout as the transport for full content) ----------------------------

TMP_BASE_DIR="${TMPDIR:-/tmp}/claude-cli-runner"
mkdir -p "$TMP_BASE_DIR"

if [ -z "$OUTPUT_FILE" ]; then
  ext="txt"
  [ "$OUTPUT_FORMAT" != "text" ] && ext="json"
  OUTPUT_FILE="$(mktemp "${TMP_BASE_DIR}/response.XXXXXX.${ext}")"
fi

if [ -n "$SCHEMA_ARG" ] && [ -z "$STRUCTURED_OUTPUT_FILE" ]; then
  STRUCTURED_OUTPUT_FILE="${OUTPUT_FILE%.*}.structured.json"
fi

# ---- Assemble the claude command ------------------------------------------

CMD=("${TIMEOUT_PREFIX[@]}" claude -p "$FULL_PROMPT" --output-format "$OUTPUT_FORMAT")

[ -n "$MAX_TURNS" ] && CMD+=(--max-turns "$MAX_TURNS")
[ -n "$SCHEMA_ARG" ] && CMD+=(--json-schema "$SCHEMA_ARG")
[ -n "$MODEL" ] && CMD+=(--model "$MODEL")
[ -n "$FALLBACK_MODEL" ] && CMD+=(--fallback-model "$FALLBACK_MODEL")
[ -n "$ALLOWED_TOOLS" ] && CMD+=(--allowedTools "$ALLOWED_TOOLS")
[ -n "$TOOLS" ] && CMD+=(--tools "$TOOLS")
EFFECTIVE_DISALLOWED_TOOLS="$HARD_DISALLOWED_TOOLS"
[ -n "$DISALLOWED_TOOLS" ] && EFFECTIVE_DISALLOWED_TOOLS="$EFFECTIVE_DISALLOWED_TOOLS,$DISALLOWED_TOOLS"
CMD+=(--disallowedTools "$EFFECTIVE_DISALLOWED_TOOLS")
[ -n "$APPEND_SYSTEM_PROMPT" ] && CMD+=(--append-system-prompt "$APPEND_SYSTEM_PROMPT")
[ -n "$ADD_DIR" ] && CMD+=(--add-dir "$ADD_DIR")
[ -n "$PERMISSION_MODE" ] && CMD+=(--permission-mode "$PERMISSION_MODE")
[ "$USE_BARE" = true ] && CMD+=(--bare)
# --verbose + partial messages make the stream fine-grained, so the output file
# grows continuously while claude is genuinely working (not just at message
# boundaries), sharpening the caller's inactivity detection.
[ "$STREAM_MODE" = true ] && CMD+=(--verbose --include-partial-messages)

# ---- Dry run ----------------------------------------------------------------

if [ "$DRY_RUN" = true ]; then
  echo "[dry-run] workdir: $WORKDIR"
  echo "[dry-run] output file (would be created): $OUTPUT_FILE"
  [ -n "$STRUCTURED_OUTPUT_FILE" ] && echo "[dry-run] structured output file (would be created): $STRUCTURED_OUTPUT_FILE"
  printf '[dry-run] command:'
  printf ' %q' "${CMD[@]}"
  echo
  exit 0
fi

# ---- Execute ------------------------------------------------------------

pushd "$WORKDIR" >/dev/null

# Capture stdout/stderr to files instead of command substitution. This preserves
# diagnostics on nonzero Claude exits and avoids losing artifacts before callers
# can inspect them.
STDERR_FILE="${OUTPUT_FILE}.stderr"
STDOUT_TMP="$(mktemp "${TMP_BASE_DIR}/stdout.XXXXXX")"

# Run claude in the background and `wait` on it so this shell can trap
# SIGTERM/SIGINT and forward them to the actual claude process. Without this,
# a signal delivered to the runner (e.g. the orchestrator killing a wedged
# worker) would not reliably reach claude, leaving an orphan. On termination we
# still fall through and emit a status envelope so the caller is never left
# guessing.
CLAUDE_PID=""
WATCHDOG_PID=""
forward_signal() {
  [ -n "$CLAUDE_PID" ] && kill -TERM "$CLAUDE_PID" 2>/dev/null || true
}
stop_watchdog() {
  [ -n "$WATCHDOG_PID" ] && kill "$WATCHDOG_PID" 2>/dev/null || true
  [ -n "$WATCHDOG_PID" ] && wait "$WATCHDOG_PID" 2>/dev/null || true
  WATCHDOG_PID=""
}
trap forward_signal TERM INT

set +e
if [ "$STREAM_MODE" = true ]; then
  # Write the event stream straight to the artifact so it grows live and a
  # watching caller can measure real inactivity by the file's size/mtime.
  "${CMD[@]}" > "$OUTPUT_FILE" 2> "$STDERR_FILE" &
else
  "${CMD[@]}" > "$STDOUT_TMP" 2> "$STDERR_FILE" &
fi
CLAUDE_PID=$!
if [ "$USE_PORTABLE_WATCHDOG" = true ]; then
  if command -v node >/dev/null 2>&1; then
    # This process is independent of Pi's in-memory timer. If Pi dies and
    # leaves this runner behind, it still terminates the Claude child at the
    # requested deadline. The runner kills it on normal completion.
    node -e '
      const [pid, seconds] = process.argv.slice(1).map(Number);
      const alive = () => { try { process.kill(pid, 0); return true; } catch { return false; } };
      setTimeout(() => {
        if (!alive()) process.exit(0);
        try { process.kill(pid, "SIGTERM"); } catch { process.exit(0); }
        setTimeout(() => { try { if (alive()) process.kill(pid, "SIGKILL"); } catch {} }, 10_000).unref();
      }, seconds * 1000);
    ' "$CLAUDE_PID" "$RUNNER_TIMEOUT" &
    WATCHDOG_PID=$!
  else
    log_err "note: --timeout=$RUNNER_TIMEOUT requested but no Node runtime found; relying on caller watchdog."
  fi
fi
wait "$CLAUDE_PID"
CLAUDE_EXIT=$?
set -e
stop_watchdog
trap - TERM INT

popd >/dev/null

# Persist the full raw result to disk. This keeps a large response out of a
# calling agent's context: the agent gets a path, not the payload. In stream
# mode the artifact was written live above; otherwise move it into place now.
# Persist even when Claude exits nonzero.
[ "$STREAM_MODE" = true ] || mv "$STDOUT_TMP" "$OUTPUT_FILE"
RESPONSE_BYTES=$(wc -c < "$OUTPUT_FILE" | tr -d ' ')
STDERR_BYTES=$(wc -c < "$STDERR_FILE" | tr -d ' ')

# In stream mode the artifact is a JSONL event log; the summary fields live in
# the final `result` event. Extract that single line so the existing jq-based
# status extraction below can parse it exactly like a plain --format json blob.
PARSE_FILE="$OUTPUT_FILE"
RESULT_TEXT=""
if [ "$STREAM_MODE" = true ]; then
  # Trailing X's only: BSD mktemp (macOS) does not treat X's as a placeholder
  # when a suffix follows, so `result.XXXXXX.json` would not be randomized.
  PARSE_FILE="$(mktemp "${TMP_BASE_DIR}/result.XXXXXX")"
  jq -c 'select(.type=="result")' "$OUTPUT_FILE" 2>/dev/null | tail -n1 > "$PARSE_FILE" || true
  if [ -s "$PARSE_FILE" ]; then
    # `head -c` intentionally closes the pipe once the bounded excerpt is read;
    # under pipefail that can make jq exit 141 on large final results. Preserve
    # the status-envelope contract by treating that SIGPIPE as a successful
    # truncation, not as a runner failure.
    RESULT_TEXT="$(jq -r '.result // .error // ""' "$PARSE_FILE" 2>/dev/null | head -c "$RESULT_TEXT_MAX" || true)"
  fi
fi

# ---- jq-based extraction / validation --------------------------------------

SUBTYPE="n/a"
NUM_TURNS="n/a"
TOTAL_COST_USD="n/a"
SESSION_ID="n/a"
IS_ERROR="false"
TERMINAL_REASON="n/a"
STOP_REASON="n/a"
ERRORS_JSON="[]"
STATUS="ok"
WRAPPER_EXIT=0
STRUCTURED_OK=false

emit_status() {
  jq -n \
    --arg status "$STATUS" \
    --arg subtype "$SUBTYPE" \
    --arg output_file "$OUTPUT_FILE" \
    --arg structured_output_file "${STRUCTURED_OUTPUT_FILE:-}" \
    --arg stderr_file "$STDERR_FILE" \
    --arg response_bytes "$RESPONSE_BYTES" \
    --arg stderr_bytes "$STDERR_BYTES" \
    --arg exit_code "$CLAUDE_EXIT" \
    --arg num_turns "$NUM_TURNS" \
    --arg total_cost_usd "$TOTAL_COST_USD" \
    --arg session_id "$SESSION_ID" \
    --argjson is_error "$IS_ERROR" \
    --arg terminal_reason "$TERMINAL_REASON" \
    --arg stop_reason "$STOP_REASON" \
    --arg result_text "$RESULT_TEXT" \
    --argjson errors "$ERRORS_JSON" \
    '{
      status: $status,
      subtype: $subtype,
      output_file: $output_file,
      structured_output_file: (if $structured_output_file == "" then null else $structured_output_file end),
      stderr_file: $stderr_file,
      response_bytes: ($response_bytes | tonumber),
      stderr_bytes: ($stderr_bytes | tonumber),
      exit_code: ($exit_code | tonumber),
      num_turns: $num_turns,
      total_cost_usd: $total_cost_usd,
      session_id: $session_id,
      is_error: $is_error,
      terminal_reason: $terminal_reason,
      stop_reason: $stop_reason,
      result_text: (if $result_text == "" then null else $result_text end),
      errors: $errors
    }'
}

# $PARSE_FILE holds the parseable result object: the whole file for --format
# json, or the extracted final `result` event for --stream. Both share the same
# field shape, so a single code path handles them.
STRUCTURED="false"
[ "$OUTPUT_FORMAT" = "json" ] && STRUCTURED="true"
[ "$STREAM_MODE" = true ] && STRUCTURED="true"

if [ "$CLAUDE_EXIT" -ne 0 ]; then
  if [ "$STRUCTURED" = "true" ] && jq -e . "$PARSE_FILE" >/dev/null 2>&1; then
    SUBTYPE="$(jq -r '.subtype // "unknown"' "$PARSE_FILE")"
    NUM_TURNS="$(jq -r '.num_turns // "n/a"' "$PARSE_FILE")"
    TOTAL_COST_USD="$(jq -r '.total_cost_usd // "n/a"' "$PARSE_FILE")"
    SESSION_ID="$(jq -r '.session_id // "n/a"' "$PARSE_FILE")"
    IS_ERROR="$(jq -r '.is_error // false' "$PARSE_FILE")"
    TERMINAL_REASON="$(jq -r '.terminal_reason // "n/a"' "$PARSE_FILE")"
    STOP_REASON="$(jq -r '.stop_reason // "n/a"' "$PARSE_FILE")"
    ERRORS_JSON="$(jq -c '.errors // []' "$PARSE_FILE")"
  fi
  STATUS="error"
  emit_status
  log_err "claude invocation failed (exit $CLAUDE_EXIT; stderr: $STDERR_FILE)."
  exit 3
fi

if [ "$STRUCTURED" = "true" ]; then
  # Validate the parseable result is JSON before doing anything else. In stream
  # mode an empty $PARSE_FILE means no `result` event was ever emitted — a real
  # failure the caller must see, not a silent success.
  if ! jq -e . "$PARSE_FILE" >/dev/null 2>&1; then
    STATUS="error"
    WRAPPER_EXIT=4
    emit_status
    if [ "$STREAM_MODE" = true ]; then
      log_err "no parseable result event in stream output (raw log at $OUTPUT_FILE)."
    else
      log_err "claude response is not valid JSON (written to $OUTPUT_FILE for inspection)."
    fi
    exit "$WRAPPER_EXIT"
  fi

  SUBTYPE="$(jq -r '.subtype // "unknown"' "$PARSE_FILE")"
  NUM_TURNS="$(jq -r '.num_turns // "n/a"' "$PARSE_FILE")"
  TOTAL_COST_USD="$(jq -r '.total_cost_usd // "n/a"' "$PARSE_FILE")"
  SESSION_ID="$(jq -r '.session_id // "n/a"' "$PARSE_FILE")"
  IS_ERROR="$(jq -r '.is_error // false' "$PARSE_FILE")"
  TERMINAL_REASON="$(jq -r '.terminal_reason // "n/a"' "$PARSE_FILE")"
  STOP_REASON="$(jq -r '.stop_reason // "n/a"' "$PARSE_FILE")"
  ERRORS_JSON="$(jq -c '.errors // []' "$PARSE_FILE")"

  if [ -n "$SCHEMA_ARG" ]; then
    if jq -e '.structured_output' "$PARSE_FILE" >/dev/null 2>&1; then
      jq '.structured_output' "$PARSE_FILE" > "$STRUCTURED_OUTPUT_FILE"
      STRUCTURED_OK=true
    else
      STATUS="error"
      WRAPPER_EXIT=4
      emit_status
      log_err "no structured_output found in claude response despite --schema being set."
      exit "$WRAPPER_EXIT"
    fi
  fi

  if [ "$IS_ERROR" = "true" ] || [[ "$SUBTYPE" == error_* ]]; then
    STATUS="claude_error"
    WRAPPER_EXIT=3
  fi
fi

# ---- stdout: agent-safe by default ------------------------------------------
#
# `summary`    -> only this fixed-shape status line. Safe to let any agent
#                 read this directly; it is small and bounded regardless of
#                 how large the actual Claude response was.
# `structured` -> status line + the validated structured_output (bounded by
#                 the caller's own schema, so still safe in typical use).
# `full`       -> status line + the entire raw response body. Intended for
#                 humans in a terminal; avoid when another agent is the
#                 caller.

emit_status

if [ "$STDOUT_MODE" = "structured" ]; then
  if [ "$STRUCTURED_OK" = true ]; then
    cat "$STRUCTURED_OUTPUT_FILE"
  else
    log_err "--stdout-mode structured requested but no structured_output was produced."
    exit 4
  fi
elif [ "$STDOUT_MODE" = "full" ]; then
  cat "$OUTPUT_FILE"
fi

exit "$WRAPPER_EXIT"
