lazyBoy/image/computer/lazyboy-shell

442 lines
16 KiB
Plaintext
Raw Normal View History

2026-09-07 13:08:10 +00:00
#!/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