lazyBoy/image/computer/lazyboy-shell

442 lines
16 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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