#!/bin/sh
# Persistent terminals for the agent, on top of tmux.
#
# A `shell` call used to be a fresh `bash -lc`: the working directory, exports,
# and anything started in the background died with the call, so the model had to
# re-derive its way back to a working shell every time. A named tmux session per
# agent terminal keeps what a human keeps - one place to stand, jobs that stay
# alive, a Ctrl-C that interrupts the right thing - and the human can watch the
# same terminal on the desktop with `show`.
#
# The API reaches it through the container exec path (one-shot, argv only, no
# streaming), so finishing is detected by markers in the pane rather than by the
# exit of the exec itself. Markers are matched only at the start of a line: the
# shell echoes the typed command, and that echo contains both markers too.
#
#   lazyboy-shell run <session> <wait_ms> <command>   run, wait, print output + exit code
#   lazyboy-shell log <session> [lines]               what the terminal shows right now
#   lazyboy-shell keys <session> <key>...             C-c / Enter / literal text
#   lazyboy-shell show <session>                     open it on the desktop for a human
#   lazyboy-shell reset <session>                    drop the session and start clean
#   lazyboy-shell list                                sessions and their state
#
# Every call is sourced into the live shell (`. file`), which is what makes
# state survive and also what would let `exit` take the terminal down; a trap on
# EXIT turns that into a normal end marker and the pane is restarted.

set -eu

# Wide enough that compiler output and `ls -l` do not wrap, short enough to stay
# cheap to capture after every call.
COLS=220
ROWS=50
HISTORY=50000
BACK=4000
# Guard the model's context: keep the head and the tail of very chatty commands.
MAX_OUT=20000
# A marker that scrolled away must not lock a terminal forever.
STALE_AFTER=600

export LANG="${LANG:-zh_TW.UTF-8}"
export LC_ALL="${LC_ALL:-zh_TW.UTF-8}"
export TMUX_TMPDIR="${TMUX_TMPDIR:-/tmp}"
STATE_DIR="${LAZYBOY_SHELL_STATE:-${TMPDIR:-/tmp}/lazyboy-shell}"

die() {
  printf 'error: %s\n' "$1" >&2
  exit 2
}

need_tmux() {
  command -v tmux >/dev/null 2>&1 ||
    die "tmux is missing from this desktop image (rebuild with: make computer)"
}

# Paths end up inside the shell code the pane sources; single quotes are the
# only thing that has to survive.
sq() {
  printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
}

# Session names are model input: keep them boring, and namespace them so an
# agent terminal never collides with a tmux session the human started.
session_of() {
  case "$1" in
    '' | *[!A-Za-z0-9._-]*) die "session name must use [A-Za-z0-9._-], got: $1" ;;
  esac
  printf 'lazyboy-%s' "$1"
}

# Pane commands name an explicit pane: a detached tmux server has no current
# pane for the session-only forms to resolve against, and set-option reads the
# target as a window, so even session options go through the pane.
pane_target() {
  printf '=%s:0.0' "$1"
}

pane_of() {
  tmux capture-pane -p -J -S "-$BACK" -t "$(pane_target "$1")" 2>/dev/null || true
}

# Tmux paints; the model reads text.
clean() {
  sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' -e 's/\x1b[()][A-Za-z0-9]//g' -e 's/\x1b[>=]//g' |
    tr -d '\r'
}

# A pane is a fixed grid, so below the prompt there are only empty rows. Tailing
# one without trimming reads as a blank screen.
trim_blanks() {
  awk '{ line[NR] = $0; if ($0 !~ /^[ \t]*$/) last = NR }
       END { for (i = 1; i <= last; i++) print line[i] }'
}

# Everything the terminal showed since this call printed its start marker.
after_start() {
  printf '%s\n' "$1" | awk -v start="LB_START $2" '
    index($0, start) == 1 { keep = 1; next }
    keep { print }'
}

finished_in() {
  printf '%s\n' "$1" | grep -q "^LB_END $2 rc=[0-9][0-9]*$"
}

exit_code_in() {
  printf '%s\n' "$1" | sed -n "s/^LB_END $2 rc=\\([0-9][0-9]*\\)\$/\\1/p" | tail -n 1
}

# The shell is back at its prompt for this call, without having printed an end
# marker: the command was interrupted (or abandoned the runner). Either way the
# terminal belongs to the next command.
ready_in() {
  after_start "$1" "$2" | grep -q '^LB_READY$'
}

is_dead() {
  [ "$(tmux display-message -p -t "$(pane_target "$1")" '#{pane_dead}' 2>/dev/null)" = "1" ]
}

# Fallback for when the integration itself is gone (the model ran `exec bash`,
# replaced PROMPT_COMMAND, ...): a shell sitting on its prompt is free.
at_prompt() {
  case "$(tmux display-message -p -t "$(pane_target "$1")" '#{pane_current_command}' 2>/dev/null)" in
    bash | -bash | zsh | -zsh | sh | dash | '' ) ;;
    *) return 1 ;;
  esac
  after_start "$(pane_of "$1")" "${2:-}" | clean | trim_blanks | tail -n 1 |
    grep -qE '[#$%>][[:space:]]*$'
}

# Free for the next command even though this call never reported a result:
# interrupted at a prompt, replaced its own shell, or died outright.
released() {
  # Distinct names: sh functions share the caller's variables.
  lb_pane="$1"
  lb_pending="$2"
  lb_name="$3"
  if ready_in "$lb_pane" "$lb_pending"; then
    return 0
  fi
  is_dead "$lb_name" || at_prompt "$lb_name" "$lb_pending"
}

truncate_out() {
  awk -v max="$MAX_OUT" '
    { if (length(all) < max) all = all $0 "\n"; else dropped = 1 }
    END {
      printf "%s", all
      if (dropped) printf "\n…（輸出過長，已截斷尾段；完整輸出請在 shell 裡用 > 寫進檔案再讀）\n"
    }'
}

# Text between this call's start marker and its end marker.
output_between() {
  printf '%s\n' "$1" | awk -v start="LB_START $2" -v end="LB_END $2 " '
    index($0, start) == 1 { keep = 1; next }
    index($0, end) == 1 { keep = 0 }
    keep { print }'
}

state_dir_ready() {
  mkdir -p "$STATE_DIR"
  # Snapshots hold exported variables, so keep them out of other users' reach.
  chmod 700 "$STATE_DIR" 2>/dev/null || true
}

# The shell in a dead pane is restarted where it left off: same directory, and
# the next command re-loads the exported variables from the last snapshot.
revive_pane() {
  name="$1"
  env_file="$STATE_DIR/$name.env"
  dir="${HOME:-/tmp}"
  if [ -f "$env_file" ]; then
    saved=$(sed -n '1p' "$env_file" 2>/dev/null || true)
    if [ -n "$saved" ] && [ -d "$saved" ]; then
      dir="$saved"
    fi
  fi
  tmux respawn-pane -k -t "$(pane_target "$name")" -c "$dir" /bin/bash -i 2>/dev/null || true
  : >"$STATE_DIR/$name.revive"
}

# REVIVED tells the caller whether it has to say something about the restart.
REVIVED=no
ensure_session() {
  name="$1"
  state_dir_ready
  if ! tmux has-session -t "=$name" 2>/dev/null; then
    tmux new-session -d -s "$name" -x "$COLS" -y "$ROWS" -c "${HOME:-/tmp}" "/bin/bash -i"
  fi
  target=$(pane_target "$name")
  # remain-on-exit keeps a dead pane readable, so `exit` in a command does not
  # swallow the output the model still needs; the history has to outlive a
  # compile.
  tmux set-option -t "$target" history-limit "$HISTORY" 2>/dev/null || true
  tmux set-option -t "$target" remain-on-exit on 2>/dev/null || true
  if is_dead "$name"; then
    revive_pane "$name"
    REVIVED=yes
  fi
}

# The shell code a call types into the pane. It is sourced, so `cd` and exports
# land in the terminal's own shell; it carries its own integration, so a model
# that clobbers PROMPT_COMMAND or replaces the shell only loses it for one call.
write_runner() {
  run_file="$1"
  name="$2"
  nonce="$3"
  command="$4"
  env_file="$STATE_DIR/$name.env"
  {
    printf '%s\n' '# LazyBoy agent terminal (see image/computer/lazyboy-shell)'
    printf 'LB_STATE_DIR=%s\n' "$(sq "$STATE_DIR")"
    printf '%s\n' 'lb_ready() { printf "\nLB_READY\n"; }'
    printf 'LB_ENV=%s\n' "$(sq "$env_file")"
    printf '%s\n' 'lb_snapshot() { { pwd -P; export -p; } >"$LB_ENV" 2>/dev/null; }'
    printf '%s\n' 'lb_exit() { printf "\nLB_END %s rc=%s\n" "${LB_NONCE:-shell}" "$1"; lb_snapshot; }'
    printf '%s\n' 'case "${PROMPT_COMMAND-}" in *lb_ready*) : ;; *) PROMPT_COMMAND="lb_ready${PROMPT_COMMAND:+;$PROMPT_COMMAND}" ;; esac'
    printf '%s\n' 'trap '"'"'lb_exit "$?"'"'"' EXIT'
    if [ -f "$STATE_DIR/$name.revive" ] && [ -f "$env_file" ]; then
      printf '%s\n' '{ cd "$(sed -n 1p -- '"$(sq "$env_file")"')" 2>/dev/null;' \
        'eval "$(tail -n +2 -- '"$(sq "$env_file")"' 2>/dev/null)"; } >/dev/null 2>&1 || true'
      rm -f "$STATE_DIR/$name.revive"
    fi
    printf 'LB_NONCE=%s\n' "$(sq "$nonce")"
    printf 'printf %s\n' "'\nLB_START $nonce\n'"
    printf '%s\n' "$command"
    printf '%s\n' '__lb_rc=$?'
    printf 'printf %s\n' "'\nLB_END $nonce rc=%s\n' \"\$__lb_rc\""
  } >"$run_file"
}

do_run() {
  [ "$#" -ge 3 ] || die "run needs <session> <wait_ms> <command>"
  session="$1"
  wait_ms="$2"
  command="$3"
  case "$wait_ms" in '' | *[!0-9]*) die "wait_ms must be a number" ;; esac
  need_tmux
  name=$(session_of "$session")
  ensure_session "$name"
  nonce="$(date +%s%N)-$$"
  pending_file="$STATE_DIR/$name.pending"
  run_file="$STATE_DIR/$name.sh"

  # An earlier call may still own this terminal. Typing now would feed the
  # running program instead of the shell, so say so rather than corrupt it.
  pending=$(sed -n 's/^nonce=\(.*\)$/\1/p' "$pending_file" 2>/dev/null || true)
  started=$(sed -n 's/^started=\(.*\)$/\1/p' "$pending_file" 2>/dev/null || true)
  if [ -n "$pending" ]; then
    pane=$(pane_of "$name")
    if finished_in "$pane" "$pending" || released "$pane" "$pending" "$name"; then
      # Finished, interrupted, or crashed: the marker is gone or meaningless,
      # and the next command can have the terminal.
      pending=""
      rm -f "$pending_file"
    elif [ -n "$started" ] && [ $(( $(date +%s) - started )) -ge "$STALE_AFTER" ] &&
      printf '%s\n' "$pane" | clean | trim_blanks | tail -n 3 | grep -qE '[\$#>] ?$'; then
      # A marker that scrolled out of the capture window would otherwise lock
      # this terminal forever; an idle prompt after ten minutes means free.
      pending=""
      rm -f "$pending_file"
    fi
    if [ -n "$pending" ]; then
      printf 'status=running session=%s\n' "$session"
      printf 'This terminal is still busy with an earlier command, so nothing was typed.\n'
      printf 'Read it with shell {"session":"%s","logLines":120}, interrupt it with\n' "$session"
      printf '{"session":"%s","keys":"C-c"}, or reset it with {"session":"%s","reset":true}.\n' "$session" "$session"
      printf -- '--- terminal ---\n'
      pane_of "$name" | clean | trim_blanks | tail -n 60 | truncate_out
      return 0
    fi
  fi

  write_runner "$run_file" "$name" "$nonce" "$command"
  {
    printf 'nonce=%s\n' "$nonce"
    printf 'started=%s\n' "$(date +%s)"
  } >"$pending_file"

  # One short typed line: the runner itself lives in a file, so nothing about
  # the command needs quoting and long commands cannot outgrow send-keys.
  target=$(pane_target "$name")
  tmux send-keys -t "$target" -l -- ". $(sq "$run_file")"
  tmux send-keys -t "$target" Enter

  deadline=$(( $(date +%s) + wait_ms / 1000 + 1 ))
  while [ "$(date +%s)" -lt "$deadline" ]; do
    pane=$(pane_of "$name")
    if finished_in "$pane" "$nonce"; then
      rm -f "$pending_file"
      printf 'status=done session=%s exit=%s\n' "$session" "$(exit_code_in "$pane" "$nonce")"
      printf -- '--- output ---\n'
      output_between "$pane" "$nonce" | clean | truncate_out
      # `exit` in a command, or a shell that died by itself: hand the next
      # command a terminal again instead of a dead pane.
      if is_dead "$name"; then
        revive_pane "$name"
        printf 'note: the shell in this terminal exited; it was restarted in the same directory,\n'
        printf 'so cd and exported variables are back but background jobs of that shell are gone.\n'
      fi
      return 0
    fi
    if is_dead "$name"; then
      break
    fi
    sleep 0.1
  done

  if is_dead "$name"; then
    rm -f "$pending_file"
    printf 'status=closed session=%s\n' "$session"
    printf 'The shell in this terminal exited before it could report a result.\n'
    printf -- '--- terminal ---\n'
    pane_of "$name" | clean | trim_blanks | tail -n 60 | truncate_out
    revive_pane "$name"
    printf 'note: it was restarted in the same directory and is ready for the next command.\n'
    return 0
  fi
  printf 'status=running session=%s waitedMs=%s\n' "$session" "$wait_ms"
  printf 'The command is still running; the output so far follows. Do not type another command into\n'
  printf 'this terminal - poll with shell {"session":"%s","logLines":120} or interrupt with {"keys":"C-c"}.\n' "$session"
  printf -- '--- terminal ---\n'
  pane_of "$name" | clean | trim_blanks | tail -n 60 | truncate_out
}

do_log() {
  [ "$#" -ge 1 ] || die "log needs <session>"
  session="$1"
  lines="${2:-80}"
  case "$lines" in '' | *[!0-9]*) die "lines must be a number" ;; esac
  need_tmux
  name=$(session_of "$session")
  tmux has-session -t "=$name" 2>/dev/null || {
    printf 'status=idle session=%s\nThis terminal has not been used yet.\n' "$session"
    return 0
  }
  pending=$(sed -n 's/^nonce=\(.*\)$/\1/p' "$STATE_DIR/$name.pending" 2>/dev/null || true)
  pane=$(pane_of "$name")
  if is_dead "$name"; then
    printf 'status=closed session=%s\n' "$session"
  elif [ -n "$pending" ] && ! finished_in "$pane" "$pending" &&
    ! released "$pane" "$pending" "$name"; then
    printf 'status=running session=%s\nThe command from the earlier call is still running.\n' "$session"
  else
    rm -f "$STATE_DIR/$name.pending"
    printf 'status=idle session=%s\n' "$session"
  fi
  printf -- '--- terminal ---\n'
  printf '%s\n' "$pane" | clean | trim_blanks | tail -n "$lines" | truncate_out
}

# A key name tmux understands is sent as a key; anything else is typed as text.
send_one() {
  target=$(pane_target "$name")
  case "$1" in
    C-* | M-* | Enter | Return | Escape | Esc | Tab | BSpace | DC | IC | \
      Up | Down | Left | Right | Home | End | PageUp | PageDown | F[1-9] | F1[0-2])
      tmux send-keys -t "$target" "$1"
      ;;
    *)
      tmux send-keys -t "$target" -l -- "$1"
      ;;
  esac
}

do_keys() {
  [ "$#" -ge 2 ] || die "keys needs <session> <key>..."
  session="$1"
  shift
  need_tmux
  name=$(session_of "$session")
  ensure_session "$name"
  for key in "$@"; do
    case "$key" in
      # Ctrl-C throws the end marker away with the command, which would leave
      # the terminal looking busy forever; the interrupt *is* the release.
      C-c | C-\\ | C-z) rm -f "$STATE_DIR/$name.pending" ;;
    esac
    send_one "$key"
  done
  printf 'status=sent session=%s keys=%s\n' "$session" "$*"
  printf 'Use shell {"session":"%s","logLines":60} to see what it did.\n' "$session"
}

do_show() {
  [ "$#" -ge 1 ] || die "show needs <session>"
  session="$1"
  need_tmux
  name=$(session_of "$session")
  ensure_session "$name"
  command -v xfce4-terminal >/dev/null 2>&1 ||
    die "no desktop terminal available to show this session"
  # Detached so the window outlives this exec: the human sees the same terminal
  # the agent types in and can click into it to take over.
  setsid nohup xfce4-terminal --disable-server --geometry=112x30+64+64 \
    --title="終端機 $session" --command="tmux attach -t $name" >/dev/null 2>&1 &
  printf 'status=shown session=%s\nThe terminal is open on the desktop screen.\n' "$session"
}

do_reset() {
  [ "$#" -ge 1 ] || die "reset needs <session>"
  need_tmux
  name=$(session_of "$1")
  tmux kill-session -t "=$name" 2>/dev/null || true
  rm -f "$STATE_DIR/$name.pending" "$STATE_DIR/$name.revive" "$STATE_DIR/$name.env"
  ensure_session "$name"
  printf 'status=reset session=%s\n' "$1"
}

do_list() {
  need_tmux
  panes=$(tmux list-panes -a -F '#{session_name}|#{pane_dead}' 2>/dev/null || true)
  if [ -z "$panes" ]; then
    printf 'status=empty\nNo agent terminals are running.\n'
    return 0
  fi
  printf '%s\n' "$panes" | while IFS='|' read -r name dead; do
    case "$name" in
      lazyboy-*)
        if [ "$dead" = 1 ]; then
          printf 'session=%s state=closed\n' "${name#lazyboy-}"
        else
          printf 'session=%s state=open\n' "${name#lazyboy-}"
        fi
        ;;
    esac
  done
}

program=${0##*/}
case "${1:-}" in
  run) shift; do_run "$@" ;;
  log) shift; do_log "$@" ;;
  keys) shift; do_keys "$@" ;;
  show) shift; do_show "$@" ;;
  reset) shift; do_reset "$@" ;;
  list) shift; do_list ;;
  *) die "usage: $program run|log|keys|show|reset|list ..." ;;
esac
