442 lines
16 KiB
Bash
Executable File
442 lines
16 KiB
Bash
Executable File
#!/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
|