# Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later # CATEGORY # 08-terminal-management # # DEPENDENCIES # tmux, screen, __jobrunner_sessions # # SYNOPSIS # jobrunner [-t ] [] [] [...] # jr [-t ] [] [] [...] # # DESCRIPTION # Runs, lists, inspects, re-attaches to, and terminates named background # jobs using tmux or GNU screen as the process engine. Unlike bkg and # detach, which discard output, a jobrunner job keeps a live terminal you # can return to later — it survives closing the shell, and `attach` # restores it in any subsequent session. # # Every subcommand has a matching flag form, and the common cases are # inferred: no arguments lists jobs, a lone name attaches to it, and a # name followed by a command runs it. # # ARGUMENTS # -t, --tool Force specific backend (tmux or screen) # run, -r, --run ... Start a named job in the background # list, -l, --list List all managed background jobs # attach, -a, --attach Re-attach interactively to a job # kill, -k, --kill Terminate a running background job # logs, -o, --output Print a job's current output, no attach # help, -h, --help Show usage help # # EXIT STATUS # 0 Command succeeded, or no jobs are running # 1 Invalid arguments, or the named job does not exist # 127 neither tmux nor screen is installed # # EXAMPLE # jobrunner run build make -j8 # jobrunner -t screen run backup rsync -a ./data remote:/backup/ # jobrunner list # jobrunner logs build # jobrunner build # jobrunner kill build # # NOTES # Detach from an attached job with Ctrl-A then D; the job keeps running. # Commands are executed directly rather than through a shell, so pipes and # redirections must be wrapped explicitly, e.g. # `jobrunner run sync fish -c 'a | b'`. function jobrunner --description 'Manage detached background jobs with tmux or GNU screen' set -l c_head (set_color --bold cyan) set -l c_cmd (set_color --bold white) set -l c_arg (set_color cyan) set -l c_flag (set_color yellow) set -l c_ok (set_color green) set -l c_err (set_color red) set -l c_dim (set_color brblack) set -l c_rst (set_color normal) set -l subcmds run list attach kill logs help \ -r --run -l --list -a --attach -k --kill -o --output -h --help # ╭──────────────────────────────────────────────────────────╮ # │ Tool Extraction │ # ╰──────────────────────────────────────────────────────────╯ set -l tool "" while test (count $argv) -gt 0 switch $argv[1] case -t set tool $argv[2] set -e argv[1..2] case --tool set tool $argv[2] set -e argv[1..2] case '--tool=*' set tool (string replace -- "--tool=" "" $argv[1]) set -e argv[1] case '-t*' set tool (string replace -r "^-t" "" $argv[1]) set -e argv[1] case '*' break end end # ╭──────────────────────────────────────────────────────────╮ # │ Help │ # ╰──────────────────────────────────────────────────────────╯ # Answered before the dependency check so usage is readable anywhere. if set -q argv[1]; and contains -- $argv[1] help -h --help echo "$c_head""Usage:$c_rst $c_cmd""jobrunner$c_rst $c_arg""[] [] [...]$c_rst" echo echo " Run and manage named background jobs backed by tmux or GNU screen." echo echo "$c_head""Subcommands:$c_rst" echo " $c_cmd""run$c_rst $c_arg"" ...$c_rst Start a named job in the background" echo " $c_cmd""list$c_rst List all managed background jobs" echo " $c_cmd""attach$c_rst $c_arg""$c_rst Re-attach interactively to a job" echo " $c_cmd""kill$c_rst $c_arg""$c_rst Terminate a running background job" echo " $c_cmd""logs$c_rst $c_arg""$c_rst Print a job's output without attaching" echo echo "$c_head""Flags:$c_rst" echo " $c_flag-t$c_rst, $c_flag--tool$c_rst $c_arg$c_rst Force specific backend (tmux or screen)" echo " $c_flag-r$c_rst, $c_flag--run$c_rst Same as $c_cmd""run$c_rst" echo " $c_flag-l$c_rst, $c_flag--list$c_rst Same as $c_cmd""list$c_rst" echo " $c_flag-a$c_rst, $c_flag--attach$c_rst Same as $c_cmd""attach$c_rst" echo " $c_flag-k$c_rst, $c_flag--kill$c_rst Same as $c_cmd""kill$c_rst" echo " $c_flag-o$c_rst, $c_flag--output$c_rst Same as $c_cmd""logs$c_rst" echo " $c_flag-h$c_rst, $c_flag--help$c_rst Show this help message" echo echo "$c_head""Shorthands:$c_rst" echo " $c_cmd""jobrunner$c_rst $c_dim""list$c_rst" echo " $c_cmd""jobrunner$c_rst $c_arg""$c_rst $c_dim""attach $c_rst" echo " $c_cmd""jobrunner$c_rst $c_arg"" ...$c_rst $c_dim""run ...$c_rst" echo echo "$c_head""Examples:$c_rst" echo " $c_cmd""jobrunner$c_rst $c_arg""run build$c_rst""$c_dim"" make -j8$c_rst" echo " $c_cmd""jobrunner$c_rst $c_arg""logs build$c_rst" echo " $c_cmd""jobrunner$c_rst $c_arg""kill build$c_rst" echo echo "$c_dim""Detach from an attached job with Ctrl-A then D.$c_rst" return 0 end # ╭──────────────────────────────────────────────────────────╮ # │ Dependency check │ # ╰──────────────────────────────────────────────────────────╯ if test -n "$tool" if not contains -- $tool tmux screen echo "$c_err""jobrunner:$c_rst invalid tool '$c_arg$tool$c_rst', must be 'tmux' or 'screen'." >&2 return 1 end if not command -q $tool echo "$c_err""jobrunner:$c_rst '$tool' is required but was not found in PATH." >&2 return 127 end else if command -q tmux set tool tmux else if command -q screen set tool screen else echo "$c_err""jobrunner:$c_rst neither 'tmux' nor 'screen' was found in PATH." >&2 return 127 end end # ╭──────────────────────────────────────────────────────────╮ # │ Argument pre-processing │ # ╰──────────────────────────────────────────────────────────╯ set -q argv[1]; or set argv list set -l cmd $argv[1] if not contains -- $cmd $subcmds if test (count $argv) -eq 1 # A lone name attaches, but only if that job actually exists. if contains -- $cmd (__jobrunner_sessions $tool | string replace -r '\t.*$' '') set argv attach $argv set cmd attach else echo "$c_err""jobrunner:$c_rst unknown subcommand or job '$c_arg$cmd$c_rst'." >&2 echo "Run $c_cmd""jobrunner --help$c_rst for usage." >&2 return 1 end else # A name plus a command line is an implicit run. set argv run $argv set cmd run end end # ╭──────────────────────────────────────────────────────────╮ # │ Dispatch │ # ╰──────────────────────────────────────────────────────────╯ switch $cmd case run -r --run if test (count $argv) -lt 3 echo "$c_head""Usage:$c_rst $c_cmd""jobrunner run$c_rst $c_arg"" ...$c_rst" >&2 return 1 end set -l name $argv[2] set -l task $argv[3..-1] # screen stores each session as a socket file named after it. if string match -q '*/*' -- $name echo "$c_err""jobrunner:$c_rst job name may not contain '/'." >&2 return 1 end if contains -- $name (__jobrunner_sessions $tool | string replace -r '\t.*$' '') echo "$c_err""jobrunner:$c_rst job '$c_arg$name$c_rst' is already running." >&2 return 1 end if test "$tool" = tmux command tmux new-session -d -s $name $task else command screen -d -m -S $name $task end or begin echo "$c_err""jobrunner:$c_rst failed to start job '$c_arg$name$c_rst'." >&2 return 1 end set -l pid for row in (__jobrunner_sessions $tool) set -l f (string split \t -- $row) test "$f[1]" = "$name"; and set pid $f[2]; and break end if set -q pid[1] echo "$c_ok""✔$c_rst Started job $c_arg$name$c_rst $c_dim(PID $pid)$c_rst" else # The job may have finished (or failed) before we looked. echo "$c_ok""✔$c_rst Started job $c_arg$name$c_rst $c_dim(already exited)$c_rst" end case list -l --list set -l rows (__jobrunner_sessions $tool) if test (count $rows) -eq 0 echo "No background jobs running." return 0 end printf '%s%-20s %-8s %-10s %s%s\n' "$c_head" JOB PID STATE STARTED "$c_rst" for row in $rows set -l f (string split \t -- $row) printf '%s%-20s%s %-8s %-10s %s%s%s\n' \ "$c_arg" $f[1] "$c_rst" $f[2] $f[3] "$c_dim" $f[4] "$c_rst" end case attach -a --attach if test (count $argv) -ne 2 echo "$c_head""Usage:$c_rst $c_cmd""jobrunner attach$c_rst $c_arg""$c_rst" >&2 return 1 end set -l name $argv[2] if not contains -- $name (__jobrunner_sessions $tool | string replace -r '\t.*$' '') echo "$c_err""jobrunner:$c_rst no such job '$c_arg$name$c_rst'." >&2 return 1 end if test "$tool" = tmux command tmux attach-session -t $name else # -x attaches to an already-attached session instead of failing. command screen -x $name end case kill -k --kill if test (count $argv) -ne 2 echo "$c_head""Usage:$c_rst $c_cmd""jobrunner kill$c_rst $c_arg""$c_rst" >&2 return 1 end set -l name $argv[2] if not contains -- $name (__jobrunner_sessions $tool | string replace -r '\t.*$' '') echo "$c_err""jobrunner:$c_rst no such job '$c_arg$name$c_rst'." >&2 return 1 end if test "$tool" = tmux command tmux kill-session -t $name else command screen -X -S $name quit end or begin echo "$c_err""jobrunner:$c_rst failed to terminate job '$c_arg$name$c_rst'." >&2 return 1 end echo "$c_ok""✔$c_rst Terminated job $c_arg$name$c_rst" case logs -o --output if test (count $argv) -ne 2 echo "$c_head""Usage:$c_rst $c_cmd""jobrunner logs$c_rst $c_arg""$c_rst" >&2 return 1 end set -l name $argv[2] if not contains -- $name (__jobrunner_sessions $tool | string replace -r '\t.*$' '') echo "$c_err""jobrunner:$c_rst no such job '$c_arg$name$c_rst'." >&2 return 1 end set -l out if test "$tool" = tmux # capture-pane -S - -p outputs the scrollback and current pane directly to stdout. set out (command tmux capture-pane -t $name -S - -p) or begin echo "$c_err""jobrunner:$c_rst could not read output of '$c_arg$name$c_rst'." >&2 return 1 end else # hardcopy -h dumps scrollback plus the visible screen to a file. # ponytail: snapshot only; add `screen -L` logging if full # since-start history is ever needed. set -l dump (command mktemp) command screen -X -S $name hardcopy -h $dump or begin command rm -f $dump echo "$c_err""jobrunner:$c_rst could not read output of '$c_arg$name$c_rst'." >&2 return 1 end set out (command cat $dump) command rm -f $dump end # hardcopy (and sometimes tmux) pad the dump with empty lines. while set -q out[1]; and test -z "$out[-1]" set -e out[-1] end if set -q out[1] printf '%s\n' $out else echo "$c_dim(no output yet)$c_rst" end case '*' echo "$c_err""jobrunner:$c_rst invalid subcommand '$c_arg$cmd$c_rst'." >&2 echo "Run $c_cmd""jobrunner --help$c_rst for usage." >&2 return 1 end end