#!/usr/bin/env bash
# logs-read.sh — Shell counterpart to the logs-read MCP tool.
#
# On rc-spawn / claude.ai-code installs the per-session evidence is the Claude
# Code JSONL transcript under the configDir, NOT a per-account stream log.
# So the bare <key> mode (Task 585) accepts a claude.ai `session_<id>` (or its
# bare suffix, or the intrinsic <uuid> / a uuid prefix), maps it to the local
# <uuid> via sessions/<pid>.json (or the <uuid>.meta.json bridgeIds, or a
# content scan), and prints one timestamp-ordered timeline merging the parent
# transcript with every subagents/agent-*.jsonl — flagging each subagent
# is_error tool_result inline (agentType + failing tool + error text). An
# explicit <key> <type> still reads the legacy per-account stream log.
#
# Modes:
#   logs-read.sh <sessionKey>                   Merged JSONL session+subagent timeline
#   logs-read.sh <sessionKey> <type>            Legacy per-account stream-log read
#   logs-read.sh --scan-subagent-errors [N]     List subagent transcripts with is_error
#                                               results (silent-failure audit; N = most-
#                                               recent N transcripts, default all)
#   logs-read.sh --tail [type] [lines]          Tail most recent file of type
#   logs-read.sh --grep <sessionKey> [type]     Legacy grep across log files
#
# Types: agent-stream (canonical, per-session tool-use/tool-result archive;
#        `system` is kept as a backwards-compatible alias), session, error,
#        heartbeat, public, server, mcp, vnc, chat-attempts
# For per-session types (agent-stream/system/session/error/public): sessionKey
# is required in the first mode. For platform-scoped types (server/vnc/
# heartbeat): the id is ignored. chat-attempts is --tail-only:
# cross-grep of edge.log + server.log for the chat-ingress quartet within
# the most recent N minutes.
#
# Every invocation writes a one-line trailer describing files searched,
# matches returned, and files rejected — empty output never leaves a reader
# guessing why. On a miss the script appends a one-line
# `[log-tee] missing-on-resolve sessionKey=<8> surface=logs-read-sh
# reason=...` entry to server.log so the writer-side existence contract is
# the authoritative signal.
#
# Exit codes:
#   0  Success (output produced)
#   1  No matches / file not found (clean, not an error)
#   2  Usage error or missing directory
set -euo pipefail

# --- Resolve PLATFORM_ROOT from script location ---
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLATFORM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"

# --- Resolve account logs directory ---
# Phase 0: single account, resolved by glob. Errors if missing.
# Accounts live at {installDir}/data/accounts/ — outside the platform/ wipe zone.
INSTALL_DIR="$(dirname "$PLATFORM_ROOT")"

# --- Resolve server log (platform-scoped, not account-scoped) ---
# Convention: configDir = ".${installDir}" (e.g. installDir "maxy" → configDir ".maxy")
INSTALL_DIR_NAME=$(basename "$INSTALL_DIR")
CONFIG_DIR="$HOME/.$INSTALL_DIR_NAME"
SERVER_LOG="$CONFIG_DIR/logs/server.log"
VNC_LOG="$CONFIG_DIR/logs/vnc-boot.log"

# --- Resolve the Claude Code state tree (Task 585) ---
# rc-spawn per-session evidence is NOT under data/accounts/<id>/logs/ — it is
# the Claude Code JSONL transcript tree under the configDir. The session
# manager exports CLAUDE_CONFIG_DIR for the spawned `claude` processes; an
# operator shell usually has it unset, so derive the brand-isolated default
# ($CONFIG_DIR/.claude) and honour an explicit override (mirrors
# jsonl-path.ts's claudeStateRoot). Layout consumed by the <key> and scan
# modes:
#   projects/<slug>/<uuid>.jsonl                           — parent transcript
#   projects/<slug>/<uuid>/subagents/agent-<hex>.jsonl     — child transcripts
#   projects/<slug>/<uuid>/subagents/agent-<hex>.meta.json — {agentType,...}
#   projects/<slug>/<uuid>.meta.json                       — sidecar (carries bridgeIds)
#   sessions/<pid>.json                                    — {sessionId, bridgeSessionId}
CLAUDE_TREE="${CLAUDE_CONFIG_DIR:-$CONFIG_DIR/.claude}"
PROJECTS_ROOT="$CLAUDE_TREE/projects"
SESSIONS_DIR="$CLAUDE_TREE/sessions"

# --- Resolve account log dirs (best-effort; required only by stream-log modes) ---
# On rc-spawn installs data/accounts/<id>/ exists but carries no logs/ subdir,
# so this array is legitimately empty. The per-session JSONL <key> mode and the
# --scan-subagent-errors mode do NOT touch it; the stream-log tail/grep/
# explicit-type modes call require_account_logs() before iterating it, which
# preserves their original exit-2 "missing directory" contract.
ACCOUNTS_DIR="$INSTALL_DIR/data/accounts"
ACCOUNT_LOG_DIRS=()
if [[ -d "$ACCOUNTS_DIR" ]]; then
  shopt -s nullglob
  for _adir in "$ACCOUNTS_DIR"/*/; do
    [[ -d "${_adir}logs" ]] && ACCOUNT_LOG_DIRS+=("${_adir}logs")
  done
  shopt -u nullglob
fi
MULTI_ACCOUNT=$(( ${#ACCOUNT_LOG_DIRS[@]} > 1 ? 1 : 0 ))

# Stream-log modes require at least one account log dir; the JSONL <key> mode
# and the scan mode do not. Called at the entry of the stream-log readers so
# the configDir-only modes never dead-end on a missing data/accounts tree.
require_account_logs() {
  if [[ ! -d "$ACCOUNTS_DIR" ]]; then
    echo "Error: accounts directory does not exist: $ACCOUNTS_DIR" >&2
    exit 2
  fi
  if [[ ${#ACCOUNT_LOG_DIRS[@]} -eq 0 ]]; then
    echo "Error: no account log directories found in $ACCOUNTS_DIR" >&2
    exit 2
  fi
}

# Account ID suffix for headers — only shown when multiple accounts exist.
account_suffix() {
  if [[ $MULTI_ACCOUNT -eq 1 ]]; then
    local path="$1"
    path="${path%/}"
    if [[ "$path" == */logs ]]; then
      local acct_id
      acct_id=$(basename "$(dirname "$path")")
      echo " [${acct_id:0:8}]"
    else
      local acct_id
      acct_id=$(basename "$(dirname "$(dirname "$path")")")
      echo " [${acct_id:0:8}]"
    fi
  fi
}

# --- Prefix map (mirrors MCP tool exactly) ---
# Per-conversation types: stream log + its siblings (stderr, sse, public).
# `agent-stream` is the canonical name for the per-conversation
# tool-use/tool-result archive; `system` is kept as a backwards-compatible
# alias.
prefix_for_type() {
  case "$1" in
    agent-stream|system) echo "claude-agent-stream-" ;;
    error)   echo "claude-agent-stderr-" ;;
    session) echo "sse-events-" ;;
    public)  echo "public-agent-stream-" ;;
    mcp)     echo "mcp-" ;;
    *)       echo "" ;;
  esac
}

# Which types are per-conversation (the first mode's convId argument applies)
is_per_conversation_type() {
  case "$1" in
    agent-stream|system|error|session|public) return 0 ;;
    *) return 1 ;;
  esac
}

SEARCH_TYPES="agent-stream error session public server mcp vnc"
VALID_TYPES="agent-stream, system, session, error, heartbeat, public, server, mcp, vnc, chat-attempts"

# chat-attempts cross-grep prefixes. One row per chat-ingress
# checkpoint emitted across edge.log + server.log: edge-side inbound/outcome,
# admin-auth gate verdict, chat-route handler entry. Absence of a row at any
# checkpoint localises the failure to one stage of the ingress pipeline.
CHAT_ATTEMPT_GREP='\[edge-admin\] inbound|\[edge-admin\] outcome|\[admin-auth\] outcome|\[chat-route\] entered'

# Authoritative sessionKey character set.
# Writer-side emits `sk_<hex16>` filenames; the underscore must
# stay in this regex or every post-1008 file becomes unretrievable
#. UUID v4 sessionKeys (legacy and current alternate format)
# also pass. Widening past this set is unsafe: session_key is concatenated
# into a glob below (`${prefix}${session_key}*.log`), so any character bash
# treats as a metacharacter (`*`, `?`, `[`, `;`, `$`, etc.) would escape
# literal matching.
SESSIONKEY_REGEX='^[a-zA-Z0-9_-]+$'

usage() {
  cat >&2 <<EOF
Usage:
  logs-read.sh <sessionKey>                   Merged JSONL session+subagent timeline
  logs-read.sh <sessionKey> <type>            Legacy per-account stream-log read
  logs-read.sh --scan-subagent-errors [N]     List subagent transcripts carrying an
                                              is_error result (silent-failure audit)
  logs-read.sh --tail [type] [lines]          Tail most recent log of type
  logs-read.sh --tail chat-attempts [min]     Cross-grep edge.log + server.log
                                              for the chat-ingress quartet
  logs-read.sh --grep <sessionKey> [type]     Legacy grep across log files

Types: $VALID_TYPES
Default type: agent-stream | Default lines: 50 | Default chat-attempts minutes: 5

A bare <sessionKey> reads the Claude Code JSONL transcript tree under the
configDir (CLAUDE_CONFIG_DIR, default \$HOME/.<brand>/.claude): it accepts a
claude.ai session_<id>, its bare suffix, or the intrinsic <uuid> (or a uuid
prefix), and prints the parent transcript merged with every subagent
transcript, flagging subagent is_error results inline. An explicit <type>
keeps the legacy per-account stream-log read.
Per-session stream-log types (agent-stream, system, session, error, public)
require sessionKey; platform-scoped types (server, vnc, heartbeat) ignore the id.
chat-attempts is --tail-only — it aggregates the four ingress checkpoints
([edge-admin] inbound, [admin-auth] outcome, [chat-route] entered,
[edge-admin] outcome) into one timeline.
EOF
  exit 2
}

validate_type() {
  local t="$1"
  case "$t" in
    agent-stream|system|session|error|heartbeat|public|server|mcp|vnc|chat-attempts) return 0 ;;
    *) echo "Error: invalid type '$t'. Valid types: $VALID_TYPES" >&2; exit 2 ;;
  esac
}

# chat-attempts cross-grep mode. Combines edge.log + server.log
# lines matching the chat-ingress quartet, filtered to the last N minutes,
# sorted chronologically. Default window: 5 minutes (matches the typical
# "agent doesn't respond" report window — operator sees the silence,
# screenshots the chat, then runs this within a few minutes).
#
# Output preserves source-file prefix so operators can see whether the line
# came from the edge process (edge.log) or maxy-ui (server.log). On a
# successful chat POST the timeline reads:
#
#   [edge-admin] inbound  method=POST path=/api/admin/chat token=present …  (edge.log)
#   [admin-auth] outcome=accept …                                           (server.log)
#   [chat-route] entered  route=admin method=POST cacheKey=… …              (server.log)
#   [edge-admin] outcome  method=POST path=/api/admin/chat status=200 …     (edge.log)
#
# Any missing row in that sequence is itself diagnostic. Three-strike rule:
# zero edge inbound = browser/network problem; inbound present + admin-auth
# missing = handler unreachable; admin-auth=reject = auth failure; entered
# missing after accept = Hono routing regression; outcome missing = upstream
# proxy crash.
chat_attempts_mode() {
  local minutes="${1:-5}"
  if ! [[ "$minutes" =~ ^[0-9]+$ ]]; then
    echo "Error: minutes must be a number, got '$minutes'" >&2
    exit 2
  fi

  local EDGE_LOG="$CONFIG_DIR/logs/edge.log"
  local since_epoch
  since_epoch=$(date -u -v-"${minutes}"M +%s 2>/dev/null || date -u -d "${minutes} minutes ago" +%s 2>/dev/null || true)
  if [[ -z "$since_epoch" ]]; then
    echo "Error: could not compute time window — neither BSD nor GNU date available" >&2
    exit 2
  fi

  # Each log line carries an ISO-8601 timestamp at the head (server.log via
  # pino, edge.log via console — both written line-buffered with leading
  # `YYYY-MM-DDTHH:MM:SS` then either `.sssZ` or a space). awk filters to
  # the time window; sed prefixes the source filename so the timeline
  # remains attributable; sort -k1 keeps chronological order.
  local filter_awk='
    BEGIN { since = '"$since_epoch"' }
    {
      ts = substr($0, 1, 19)
      gsub("T", " ", ts)
      cmd = "date -u -j -f \"%Y-%m-%d %H:%M:%S\" \"" ts "\" +%s 2>/dev/null || date -u -d \"" ts "\" +%s 2>/dev/null"
      cmd | getline epoch
      close(cmd)
      if (epoch >= since) print
    }
  '

  local hits=0
  local tmp
  tmp=$(mktemp -t chat-attempts.XXXXXX)
  trap 'rm -f "$tmp"' EXIT

  if [[ -f "$EDGE_LOG" ]]; then
    grep -E "$CHAT_ATTEMPT_GREP" "$EDGE_LOG" 2>/dev/null | awk "$filter_awk" | sed 's|^|edge.log  |' >> "$tmp" || true
  fi
  if [[ -f "$SERVER_LOG" ]]; then
    grep -E "$CHAT_ATTEMPT_GREP" "$SERVER_LOG" 2>/dev/null | awk "$filter_awk" | sed 's|^|server.log|' >> "$tmp" || true
  fi

  hits=$(wc -l < "$tmp" | tr -d ' ')
  if [[ "$hits" -eq 0 ]]; then
    echo "# chat-attempts (last ${minutes} min) — no events" >&2
    echo "-- trailer: type=chat-attempts window_min=${minutes} matches=0 searched=edge.log,server.log" >&2
    exit 1
  fi

  echo "# chat-attempts (last ${minutes} min — chronological)"
  echo ""
  # Sort by the timestamp column that begins immediately after the source-file
  # prefix. Prefix is "edge.log  " or "server.log" — both 10 chars before the
  # timestamp begins, so column 11 onward is the chronological key.
  sort -k1.11 "$tmp"
  echo "" >&2
  echo "-- trailer: type=chat-attempts window_min=${minutes} matches=${hits} searched=edge.log,server.log" >&2
  exit 0
}

# --- Per-session mode: prefix-match {prefix}{sessionKey-prefix}*.log ---
#
# one filename shape per session: {prefix}{sessionKey}.log.
# The operator types an 8-char-or-greater sessionKey prefix; we glob
# `${prefix}${session_key}*.log` across every account log dir. The earlier
# full vs preflush two-shape contract is gone: sessionKey is the single
# identifier on disk, mounted from turn 1.
#
# Three trailer outcomes:
#   single hit  → exit 0   (success)
#   zero hits   → exit 1 + appends `[log-tee] missing-on-resolve sessionKey=<8>
#                          surface=logs-read-sh reason=...` to server.log so
#                          the writer-side existence contract is the
#                          authoritative signal.
#   2+ hits     → exit 1 + reason=ambiguous-prefix candidates=[...]; never picks
#
# MIRROR — keep these three sites in lockstep:
#   - platform/ui/app/lib/logs-read-resolve.ts (TS helper, exact-match)
#   - platform/plugins/admin/mcp/src/index.ts  (MCP tool, exact-match)
per_conversation_mode() {
  local session_key="$1"
  local filter_type="${2:-agent-stream}"

  if [[ -z "$session_key" ]]; then
    echo "Error: sessionKey cannot be empty" >&2
    exit 2
  fi

  # Reject shell metacharacters in session_key — the prefix-match glob below
  # would otherwise turn user input into a shell pattern. SessionKeys are
  # `sk_<hex16>` or UUID v4; both fit $SESSIONKEY_REGEX defined
  # near the top of the script. widened this from `[a-zA-Z0-9-]`
  # after the underscore-bearing format made every post-1008
  # session unretrievable.
  if [[ ! "$session_key" =~ $SESSIONKEY_REGEX ]]; then
    # Observability: surface the rejection before the operator-visible error
    # so the adherence runner can prove writer/reader divergence.
    # Best-effort append; mirrors the missing-on-resolve idiom below.
    local rej_ts bad_char
    rej_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    bad_char=$(printf '%s' "$session_key" | tr -d 'a-zA-Z0-9_-' | head -c 1)
    echo "${rej_ts} [logs-read] regex-rejected sessionKey-prefix=${session_key:0:12} regex=${SESSIONKEY_REGEX} sample=${bad_char}" >> "$SERVER_LOG" 2>/dev/null || true
    echo "Error: sessionKey contains invalid characters (allowed: a-z, A-Z, 0-9, _, -)" >&2
    exit 2
  fi

  validate_type "$filter_type"

  # Platform-scoped types shortcut to their fixed files regardless of id.
  case "$filter_type" in
    server|vnc|heartbeat|mcp)
      echo "# note: type=$filter_type is platform-scoped; sessionKey ignored — showing fixed file" >&2
      tail_mode "$filter_type" "50"
      return
      ;;
  esac

  # Explicit-type per-session reads come from the per-account stream logs;
  # preserve the original exit-2 contract when that tree is absent.
  require_account_logs

  local prefix
  prefix=$(prefix_for_type "$filter_type")
  if [[ -z "$prefix" ]]; then
    echo "Error: no prefix mapping for type '$filter_type'" >&2
    exit 2
  fi

  local glob="${prefix}${session_key}*.log"
  local searched=0

  local -a hits=()
  local -a hit_dirs=()
  shopt -s nullglob
  for log_dir in "${ACCOUNT_LOG_DIRS[@]}"; do
    searched=$((searched + 1))
    for f in "$log_dir"/${prefix}${session_key}*.log; do
      hits+=("$f")
      hit_dirs+=("$log_dir")
    done
  done
  shopt -u nullglob

  if [[ ${#hits[@]} -gt 1 ]]; then
    local -a candidate_names=()
    local cf
    for cf in "${hits[@]}"; do
      candidate_names+=("$(basename "$cf")")
    done
    local joined
    joined=$(IFS=','; printf '%s' "${candidate_names[*]}")
    echo "-- trailer: sessionKey=$session_key type=$filter_type searched=$searched matches=${#hits[@]} reason=ambiguous-prefix candidates=[${joined}]" >&2
    exit 1
  fi

  if [[ ${#hits[@]} -eq 1 ]]; then
    local hit_path="${hits[0]}"
    local match_dir="${hit_dirs[0]}"
    echo "## $(basename "$hit_path") ($filter_type)$(account_suffix "$match_dir")"
    cat "$hit_path"
    echo "" >&2
    echo "-- trailer: sessionKey=$session_key type=$filter_type searched=$searched matched=1" >&2
    exit 0
  fi

  # missing-on-resolve emission. Best-effort; never fails the
  # invocation when server.log is unwritable.
  local miss_ts
  miss_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  echo "${miss_ts} [log-tee] missing-on-resolve sessionKey=${session_key:0:8} surface=logs-read-sh reason=\"file-not-found type=${filter_type}\"" >> "$SERVER_LOG" 2>/dev/null || true

  echo "-- trailer: sessionKey=$session_key type=$filter_type searched=$searched found=0 tried=[${glob}] reason=file-not-found" >&2
  exit 1
}

# --- Legacy grep mode (backward compatibility with sessionKey-tagged lines) ---
grep_mode() {
  local session_key="$1"
  local filter_type="${2:-}"

  if [[ -z "$session_key" ]]; then
    echo "Error: session key cannot be empty" >&2
    exit 2
  fi

  # grep mode reads per-account stream logs; preserve its missing-dir contract.
  require_account_logs

  if [[ -n "$filter_type" ]]; then
    validate_type "$filter_type"
  fi

  local types_to_search
  if [[ -n "$filter_type" && "$filter_type" != "heartbeat" ]]; then
    types_to_search="$filter_type"
  else
    types_to_search="$SEARCH_TYPES"
  fi

  local found=0
  local files_searched=0
  local files_rejected=0

  for log_type in $types_to_search; do
    if [[ "$log_type" == "server" ]]; then
      if [[ -f "$SERVER_LOG" ]]; then
        files_searched=$((files_searched + 1))
        local result
        if result=$(grep -F "$session_key" "$SERVER_LOG" 2>/dev/null); then
          if [[ -n "$result" ]]; then
            [[ $found -gt 0 ]] && echo ""
            echo "## server.log (server)"
            echo "$result"
            found=$((found + 1))
          fi
        fi
      else
        files_rejected=$((files_rejected + 1))
      fi
      continue
    fi

    if [[ "$log_type" == "vnc" ]]; then
      if [[ -f "$VNC_LOG" ]]; then
        files_searched=$((files_searched + 1))
        local result
        if result=$(grep -F "$session_key" "$VNC_LOG" 2>/dev/null); then
          if [[ -n "$result" ]]; then
            [[ $found -gt 0 ]] && echo ""
            echo "## vnc-boot.log (vnc)"
            echo "$result"
            found=$((found + 1))
          fi
        fi
      else
        files_rejected=$((files_rejected + 1))
      fi
      continue
    fi

    local prefix
    prefix=$(prefix_for_type "$log_type")
    [[ -z "$prefix" ]] && continue

    for log_dir in "${ACCOUNT_LOG_DIRS[@]}"; do
      shopt -s nullglob
      local files=("$log_dir"/${prefix}*.log)
      shopt -u nullglob
      IFS=$'\n' files=($(printf '%s\n' "${files[@]}" | sort)); unset IFS

      for filepath in "${files[@]}"; do
        files_searched=$((files_searched + 1))
        local file
        file=$(basename "$filepath")

        local result
        if result=$(grep -F "$session_key" "$filepath" 2>/dev/null); then
          if [[ -n "$result" ]]; then
            [[ $found -gt 0 ]] && echo ""
            echo "## $file ($log_type)$(account_suffix "$log_dir")"
            echo "$result"
            found=$((found + 1))
          fi
        fi
      done
    done
  done

  if [[ $found -eq 0 ]]; then
    echo "No log lines found for session key \"$session_key\"." >&2
    echo "-- trailer: sessionKey=$session_key files_searched=$files_searched files_rejected=$files_rejected matches=0" >&2
    exit 1
  fi
  echo "" >&2
  echo "-- trailer: sessionKey=$session_key files_searched=$files_searched files_rejected=$files_rejected matches=$found" >&2
  exit 0
}

tail_mode() {
  local log_type="${1:-agent-stream}"
  local lines="${2:-50}"

  # chat-attempts is cross-grep + time-windowed, not single-file
  # tail. Dispatch before validate_type's numeric `lines` check (which would
  # be wrong here — argument 2 is `minutes`, not `lines`).
  if [[ "$log_type" == "chat-attempts" ]]; then
    chat_attempts_mode "${lines:-5}"
    return
  fi

  validate_type "$log_type"

  if ! [[ "$lines" =~ ^[0-9]+$ ]]; then
    echo "Error: lines must be a number, got '$lines'" >&2
    exit 2
  fi

  if [[ "$log_type" == "heartbeat" ]]; then
    require_account_logs
    local hb_file=""
    for log_dir in "${ACCOUNT_LOG_DIRS[@]}"; do
      local candidate="${log_dir}/check-due-events.log"
      if [[ -f "$candidate" ]]; then
        if [[ -z "$hb_file" ]] || [[ "$candidate" -nt "$hb_file" ]]; then
          hb_file="$candidate"
        fi
      fi
    done
    if [[ -z "$hb_file" ]]; then
      echo "No heartbeat log found yet. The platform cron may not have run."
      exit 1
    fi
    echo "# check-due-events.log$(account_suffix "$hb_file")"
    echo ""
    tail -n "$lines" "$hb_file"
    exit 0
  fi

  if [[ "$log_type" == "server" ]]; then
    if [[ ! -f "$SERVER_LOG" ]]; then
      echo "No server log found at $SERVER_LOG"
      exit 1
    fi
    echo "# server.log"
    echo ""
    tail -n "$lines" "$SERVER_LOG"
    exit 0
  fi

  if [[ "$log_type" == "vnc" ]]; then
    if [[ ! -f "$VNC_LOG" ]]; then
      echo "No vnc-boot log found at $VNC_LOG"
      exit 1
    fi
    echo "# vnc-boot.log"
    echo ""
    tail -n "$lines" "$VNC_LOG"
    exit 0
  fi

  # Reached only for per-session stream-log types (agent-stream/error/session/
  # public/mcp); these live under the per-account log dirs.
  require_account_logs

  local prefix
  prefix=$(prefix_for_type "$log_type")

  local all_files=()
  for log_dir in "${ACCOUNT_LOG_DIRS[@]}"; do
    shopt -s nullglob
    local files=("$log_dir"/${prefix}*.log)
    shopt -u nullglob
    all_files+=("${files[@]}")
  done

  if [[ ${#all_files[@]} -eq 0 ]]; then
    echo "No log file found for type '$log_type'"
    exit 1
  fi

  # Sort by mtime descending — most recent first. Per-conversation filenames
  # no longer sort chronologically by name (UUID suffix), so mtime is the
  # only reliable "most recently active" ordering.
  local match
  match=$(ls -t "${all_files[@]}" | head -1)
  local match_name
  match_name=$(basename "$match")

  echo "# ${match_name}$(account_suffix "$match")"
  echo ""
  tail -n "$lines" "$match"
  exit 0
}

# === rc-spawn JSONL retrieval (Task 585) ===================================
# The bare <key> mode and --scan-subagent-errors read the Claude Code JSONL
# transcript tree under the configDir, not the per-account stream logs.
# python3 is the JSON dependency, deliberately not node: the systemd unit
# PATH includes /usr/bin but not nvm's node bin, and python3 is already
# required by setup-account.sh / seed-neo4j.sh, so this survives a minimal
# non-interactive SSH PATH. argv[1] selects the mode (timeline|scan); the
# program is fed via -c so logs-read.sh stays a single self-contained file.
JSONL_PY=$(cat <<'PY'
import sys, os, re, json, glob

MODE = sys.argv[1] if len(sys.argv) > 1 else ''

UUID_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.I)
HEXPREFIX_RE = re.compile(r'^[0-9a-f]{8,}$', re.I)

def clip(s, n):
    if not isinstance(s, str):
        s = '' if s is None else str(s)
    s = s.replace('\n', ' ').strip()
    return s if len(s) <= n else s[:n-1] + '…'

def load_jsonl(path):
    rows = []
    try:
        with open(path, 'r', errors='replace') as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    rows.append(json.loads(line))
                except Exception:
                    continue
    except OSError:
        pass
    return rows

def load_json(path):
    try:
        with open(path, 'r', errors='replace') as f:
            return json.load(f)
    except Exception:
        return None

def parent_jsonl_exact(projects_root, uuid):
    for p in glob.glob(os.path.join(projects_root, '*', uuid + '.jsonl')):
        return p
    for p in glob.glob(os.path.join(projects_root, '*', 'archive', uuid + '.jsonl')):
        return p
    return None

def parent_jsonls_by_prefix(projects_root, prefix):
    out = set()
    for p in glob.glob(os.path.join(projects_root, '*', prefix + '*.jsonl')):
        if UUID_RE.match(os.path.basename(p)[:-6]):
            out.add(p)
    return sorted(out)

def agent_type(af):
    o = load_json(af[:-len('.jsonl')] + '.meta.json')
    if isinstance(o, dict) and isinstance(o.get('agentType'), str) and o['agentType']:
        return o['agentType']
    return None

def fmt_input(d):
    if isinstance(d, dict):
        for k in ('command', 'file_path', 'pattern', 'path', 'description', 'prompt'):
            v = d.get(k)
            if isinstance(v, str) and v:
                return '%s=%s' % (k, v)
        try:
            return json.dumps(d, ensure_ascii=False)
        except Exception:
            return str(d)
    return '' if d is None else str(d)

def result_text(block, row):
    c = block.get('content')
    if isinstance(c, str) and c.strip():
        return c
    if isinstance(c, list):
        parts = [x.get('text') if isinstance(x, dict) else x for x in c]
        joined = ' '.join(p for p in parts if isinstance(p, str))
        if joined.strip():
            return joined
    tur = row.get('toolUseResult')
    return tur if isinstance(tur, str) else ''

# Map a claude.ai session key to the local intrinsic <uuid> + its parent JSONL.
# Order: (a) the key already IS a uuid (or a hex prefix of one) on disk;
# (b) a sessions/<pid>.json whose bridgeSessionId matches (live/recent);
# (c) a <uuid>.meta.json whose bridgeIds carries the suffix (persistent, so an
# ENDED session whose PID file was deleted still resolves); (d) content grep
# of top-level transcripts (last resort). Returns (uuid, parent_path, via) or
# (None, None, reason).
def resolve_uuid(projects_root, sessions_dir, key):
    suffix = key[len('session_'):] if key.startswith('session_') else key
    candidates = [key] if key == suffix else [key, suffix]
    for c in candidates:
        p = parent_jsonl_exact(projects_root, c)
        if p:
            return (os.path.basename(p)[:-6], p, 'uuid-exact')
        if HEXPREFIX_RE.match(c):
            hits = parent_jsonls_by_prefix(projects_root, c)
            if len(hits) == 1:
                return (os.path.basename(hits[0])[:-6], hits[0], 'uuid-prefix')
            if len(hits) > 1:
                return (None, None, 'ambiguous-prefix candidates=%d' % len(hits))
    for pf in glob.glob(os.path.join(sessions_dir, '*.json')):
        o = load_json(pf)
        if not isinstance(o, dict):
            continue
        sid, bsid = o.get('sessionId'), o.get('bridgeSessionId')
        if not (isinstance(sid, str) and sid):
            continue
        if isinstance(bsid, str) and bsid and (
            bsid == key or bsid == 'session_' + suffix or bsid.replace('session_', '') == suffix
        ):
            return (sid, parent_jsonl_exact(projects_root, sid), 'pid-bridge')
    for mf in glob.glob(os.path.join(projects_root, '*', '*.meta.json')):
        o = load_json(mf)
        if isinstance(o, dict) and isinstance(o.get('bridgeIds'), list) and suffix in o['bridgeIds']:
            uuid = os.path.basename(mf)[:-len('.meta.json')]
            if UUID_RE.match(uuid):
                return (uuid, parent_jsonl_exact(projects_root, uuid), 'bridgeids')
    # Match the verbatim key (the full session_<suffix> the operator passed, or
    # the bare token) — not a loose bare-suffix substring, which could collide
    # with unrelated body text. Refuse on multiple matches rather than silently
    # pick the wrong session (faithfulness over a best-effort guess).
    matches = []
    for p in glob.glob(os.path.join(projects_root, '*', '*.jsonl')):
        if not UUID_RE.match(os.path.basename(p)[:-6]):
            continue
        try:
            with open(p, 'r', errors='replace') as f:
                blob = f.read()
        except OSError:
            continue
        if key in blob:
            matches.append(p)
    if len(matches) == 1:
        return (os.path.basename(matches[0])[:-6], matches[0], 'content-grep')
    if len(matches) > 1:
        return (None, None, 'ambiguous-content-match candidates=%d' % len(matches))
    return (None, None, 'not-found')

def subagent_dir(projects_root, uuid):
    for d in glob.glob(os.path.join(projects_root, '*', uuid, 'subagents')):
        return d
    return None

# Append (ts, source, kind, text) tuples for one transcript. tool_use_id is
# matched back to the assistant tool_use that issued it, so an is_error
# tool_result names the failing tool. is_sub distinguishes a subagent's
# failure (the task's target, flagged 'SUBAGENT ERROR' and counted) from the
# parent session's own tool error (shown plainly, never counted as a subagent
# error). Returns the count of SUBAGENT errors found (0 for the parent).
def collect_events(path, source, events, is_sub):
    rows = load_jsonl(path)
    id2tool = {}
    for r in rows:
        if not isinstance(r, dict):
            continue
        msg = r.get('message') if isinstance(r.get('message'), dict) else {}
        content = msg.get('content')
        if isinstance(content, list):
            for b in content:
                if isinstance(b, dict) and b.get('type') == 'tool_use' and b.get('id'):
                    id2tool[b['id']] = b.get('name')
    n_err = 0
    for r in rows:
        if not isinstance(r, dict):
            continue
        ts = r.get('timestamp') or ''
        t = r.get('type')
        msg = r.get('message') if isinstance(r.get('message'), dict) else {}
        content = msg.get('content')
        if t == 'assistant':
            if isinstance(content, list):
                for b in content:
                    if not isinstance(b, dict):
                        continue
                    if b.get('type') == 'text' and isinstance(b.get('text'), str) and b['text'].strip():
                        events.append((ts, source, 'text', 'assistant: ' + clip(b['text'], 160)))
                    elif b.get('type') == 'tool_use':
                        events.append((ts, source, 'tool', 'tool_use: %s(%s)' % (b.get('name'), clip(fmt_input(b.get('input')), 120))))
            elif isinstance(content, str) and content.strip():
                events.append((ts, source, 'text', 'assistant: ' + clip(content, 160)))
        elif t == 'user':
            if isinstance(content, list):
                for b in content:
                    if isinstance(b, dict) and b.get('type') == 'tool_result' and b.get('is_error'):
                        tool = id2tool.get(b.get('tool_use_id'))
                        flag = '‼ SUBAGENT ERROR' if is_sub else '‼ tool error'
                        events.append((ts, source, 'error', '%s  tool=%s  error="%s"' % (flag, tool or '?', clip(result_text(b, r), 200))))
                        if is_sub:
                            n_err += 1
            elif isinstance(content, str) and content.strip():
                events.append((ts, source, 'text', 'user: ' + clip(content, 160)))
    return n_err

def timeline(projects_root, sessions_dir, key):
    uuid, parent_path, via = resolve_uuid(projects_root, sessions_dir, key)
    if not uuid:
        sys.stderr.write('-- trailer: key=%s resolved=no reason=%s\n' % (key, via))
        return 1
    events = []
    if parent_path and os.path.exists(parent_path):
        collect_events(parent_path, 'parent', events, False)
    sdir = subagent_dir(projects_root, uuid)
    sub_count = err_count = 0
    if sdir:
        for af in sorted(glob.glob(os.path.join(sdir, 'agent-*.jsonl'))):
            sub_count += 1
            err_count += collect_events(af, agent_type(af) or os.path.basename(af)[:-6], events, True)
    events.sort(key=lambda e: e[0])
    print('# session %s  (resolved via %s)' % (uuid, via))
    print('#   parent:    %s' % (parent_path or '(no parent transcript on disk)'))
    print('#   subagents: %d   subagent-errors: %d' % (sub_count, err_count))
    print('')
    for ts, source, _kind, text in events:
        print('%s  [%s] %s' % (ts or ('-' * 24), source, text))
    sys.stderr.write('-- trailer: key=%s resolved=%s uuid=%s subagents=%d errors=%d\n' % (key, via, uuid, sub_count, err_count))
    return 0

def scan(projects_root, limit):
    files = glob.glob(os.path.join(projects_root, '*', '*', 'subagents', 'agent-*.jsonl'))
    if limit > 0:
        files = sorted(files, key=os.path.getmtime, reverse=True)[:limit]
    files = sorted(files)
    findings = []
    for af in files:
        rows = load_jsonl(af)
        id2tool = {}
        for r in rows:
            if not isinstance(r, dict):
                continue
            msg = r.get('message') if isinstance(r.get('message'), dict) else {}
            content = msg.get('content')
            if isinstance(content, list):
                for b in content:
                    if isinstance(b, dict) and b.get('type') == 'tool_use' and b.get('id'):
                        id2tool[b['id']] = b.get('name')
        errs = []
        for r in rows:
            if not isinstance(r, dict):
                continue
            msg = r.get('message') if isinstance(r.get('message'), dict) else {}
            content = msg.get('content')
            if isinstance(content, list):
                for b in content:
                    if isinstance(b, dict) and b.get('type') == 'tool_result' and b.get('is_error'):
                        errs.append((id2tool.get(b.get('tool_use_id')), result_text(b, r)))
        if errs:
            parent_uuid = os.path.basename(os.path.dirname(os.path.dirname(af)))
            findings.append((af, agent_type(af), parent_uuid, errs))
    print('# subagent-error scan: %d transcript(s) scanned, %d with errors' % (len(files), len(findings)))
    print('')
    for af, atype, parent_uuid, errs in findings:
        print('‼ %s  agentType=%s  parent=%s' % (os.path.basename(af), atype or '?', parent_uuid[:8]))
        for tool, etext in errs:
            low = (etext or '').lower()
            extra = '  [exit-127 / command-not-found]' if ('command not found' in low or 'exit code 127' in low) else ''
            print('    tool=%s  error="%s"%s' % (tool or '?', clip(etext, 200), extra))
    sys.stderr.write('-- trailer: scan scanned=%d with_errors=%d\n' % (len(files), len(findings)))
    return 0

if MODE == 'timeline':
    sys.exit(timeline(sys.argv[2], sys.argv[3], sys.argv[4]))
elif MODE == 'scan':
    lim = 0
    if len(sys.argv) > 3 and sys.argv[3]:
        try:
            lim = int(sys.argv[3])
        except ValueError:
            lim = 0
    sys.exit(scan(sys.argv[2], lim))
else:
    sys.stderr.write('jsonl helper: unknown mode %r\n' % MODE)
    sys.exit(2)
PY
)

# Best-effort missing-on-resolve marker, mirroring per_conversation_mode so the
# writer-side existence contract still records a <key> miss on server.log.
emit_missing_on_resolve() {
  local key="$1" reason="$2" ts
  ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  echo "${ts} [log-tee] missing-on-resolve sessionKey=${key:0:8} surface=logs-read-sh reason=\"${reason}\"" >> "$SERVER_LOG" 2>/dev/null || true
}

# Bare <key> mode: resolve the claude.ai session key against the configDir
# Claude tree and print a merged parent+subagent timeline with every subagent
# is_error tool_result flagged inline.
jsonl_timeline_mode() {
  local key="$1"
  if [[ -z "$key" ]]; then
    echo "Error: sessionKey cannot be empty" >&2
    exit 2
  fi
  if [[ ! "$key" =~ $SESSIONKEY_REGEX ]]; then
    echo "Error: sessionKey contains invalid characters (allowed: a-z, A-Z, 0-9, _, -)" >&2
    exit 2
  fi
  if ! command -v python3 >/dev/null 2>&1; then
    echo "Error: python3 is required to read the JSONL session timeline" >&2
    exit 2
  fi
  if [[ ! -d "$PROJECTS_ROOT" ]]; then
    echo "Error: Claude project tree not found at $PROJECTS_ROOT" >&2
    echo "  (set CLAUDE_CONFIG_DIR if this install uses a non-default config dir)" >&2
    emit_missing_on_resolve "$key" "projects-root-missing"
    exit 1
  fi
  local rc=0
  python3 -c "$JSONL_PY" timeline "$PROJECTS_ROOT" "$SESSIONS_DIR" "$key" || rc=$?
  [[ $rc -ne 0 ]] && emit_missing_on_resolve "$key" "jsonl-not-resolved"
  exit $rc
}

# Standing scan: list every subagents/agent-*.jsonl carrying an is_error
# tool_result, so a silently-failed delivery (e.g. exit-127 / command-not-found
# on a nonexistent tool name) is discoverable without reproduction. Optional N
# limits the scan to the N most-recently-modified subagent transcripts.
scan_subagent_errors_mode() {
  local limit="${1:-}"
  if [[ -n "$limit" && ! "$limit" =~ ^[0-9]+$ ]]; then
    echo "Error: scan limit must be a number, got '$limit'" >&2
    exit 2
  fi
  if ! command -v python3 >/dev/null 2>&1; then
    echo "Error: python3 is required for the subagent-error scan" >&2
    exit 2
  fi
  if [[ ! -d "$PROJECTS_ROOT" ]]; then
    echo "Error: Claude project tree not found at $PROJECTS_ROOT" >&2
    exit 1
  fi
  python3 -c "$JSONL_PY" scan "$PROJECTS_ROOT" "${limit:-}"
  exit $?
}

if [[ $# -eq 0 ]]; then
  usage
fi

case "$1" in
  --tail)
    shift
    tail_mode "${1:-agent-stream}" "${2:-50}"
    ;;
  --grep)
    shift
    [[ $# -eq 0 ]] && usage
    grep_mode "$1" "${2:-}"
    ;;
  --scan-subagent-errors)
    shift
    scan_subagent_errors_mode "${1:-}"
    ;;
  --help|-h)
    usage
    ;;
  -*)
    echo "Error: unknown option '$1'" >&2
    usage
    ;;
  *)
    # Bare <key> resolves the configDir JSONL timeline (Task 585). An explicit
    # <key> <type> keeps the legacy per-account stream-log read unchanged.
    if [[ -n "${2:-}" ]]; then
      per_conversation_mode "$1" "$2"
    else
      jsonl_timeline_mode "$1"
    fi
    ;;
esac
