#!/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 run, wait, print output + exit code # lazyboy-shell log [lines] what the terminal shows right now # lazyboy-shell keys ... C-c / Enter / literal text # lazyboy-shell show open it on the desktop for a human # lazyboy-shell reset 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="$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="$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="$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="$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 " 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