feat(jobrunner): add named background job manager backed by GNU screen

Adds jobrunner (short alias jr) for running, listing, inspecting,
re-attaching to, and terminating detached background tasks. Unlike bkg
and detach, which discard output, a jobrunner job keeps a live terminal
that survives closing the shell and can be restored later with attach.

Includes the __jobrunner_sessions parser shared by the function and its
completions, dynamic job-name completions, and screen in the fish-deps
catalog.
This commit is contained in:
2026-08-02 04:40:07 -04:00
parent a2d52e1ec2
commit 674a50ea05
8 changed files with 459 additions and 5 deletions
+43
View File
@@ -0,0 +1,43 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# SYNOPSIS
# __jobrunner_sessions
#
# DESCRIPTION
# Parses `screen -ls` into machine-readable rows, one per active session:
# name, PID, state, and start time separated by tabs. Shared by jobrunner
# and its completions so both agree on what a session is named. Prints
# nothing when no sessions exist.
#
# EXIT STATUS
# 0 Parsed successfully (including the zero-session case)
# 1 screen is not installed
#
# RETURNS
# One line per session: <name>\t<pid>\t<state>\t<started>
#
# EXAMPLE
# __jobrunner_sessions
# __jobrunner_sessions | string replace -r '\t.*$' ''
function __jobrunner_sessions --description 'List active screen sessions as name/pid/state/started rows'
command -q screen; or return 1
# screen -ls exits 1 when nothing is running; the parse handles that.
for line in (command screen -ls 2>/dev/null)
# Session rows are tab-indented and start with "<pid>.<name>".
set -l m (string match -r '^\s+(\d+)\.([^\t]+)\t(.*)$' -- $line)
or continue
# Trailing fields are parenthesized: "(<date>)\t(<state>)", and some
# screen builds omit the date, so read the state off the end.
set -l fields (string split \t -- $m[4])
set -l state (string trim -c '()' -- $fields[-1])
set -l started ""
if test (count $fields) -gt 1
set started (string trim -c '()' -- $fields[1])
end
printf '%s\t%s\t%s\t%s\n' $m[3] $m[2] $state $started
end
end
+5 -5
View File
@@ -16,27 +16,27 @@ function _fish_deps_catalog
set -g _fdc_bins \
uv cargo fish starship fzf zoxide direnv paru yay \
wakatime tailscale \
eza lsd bat btop dust duf prettyping ov rg lazygit lazydocker trash kitty wezterm python3 yt-dlp
eza lsd bat btop dust duf prettyping ov rg lazygit lazydocker trash kitty wezterm python3 yt-dlp screen
set -g _fdc_tiers \
rec rec req rec req rec rec rec rec \
int int \
rec rec rec rec rec rec rec rec rec rec rec rec rec rec rec rec
rec rec rec rec rec rec rec rec rec rec rec rec rec rec rec rec rec
set -g _fdc_cargo \
"" "" "" starship "" zoxide "" "" "" \
"" "" \
eza lsd bat "" du-dust "" "" ov ripgrep "" "" trashy "" "" "" ""
eza lsd bat "" du-dust "" "" ov ripgrep "" "" trashy "" "" "" "" ""
set -g _fdc_pm \
uv cargo fish starship fzf zoxide direnv "" yay \
wakatime tailscale \
eza lsd bat btop dust duf prettyping ov ripgrep lazygit lazydocker trash kitty wezterm python yt-dlp
eza lsd bat btop dust duf prettyping ov ripgrep lazygit lazydocker trash kitty wezterm python yt-dlp screen
set -g _fdc_special \
curl-uv rustup-installer git-cargo-fish curl-installer fzf-update "" "" paru-build yay-build \
wakatime-binary "" \
"" "" "" "" "" "" "" "" "" "" curl-lazydocker "" "" "" "" ""
"" "" "" "" "" "" "" "" "" "" curl-lazydocker "" "" "" "" "" ""
end
# SYNOPSIS
+258
View File
@@ -0,0 +1,258 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# CATEGORY
# 08-terminal-management
#
# DEPENDENCIES
# screen, __jobrunner_sessions
#
# SYNOPSIS
# jobrunner [<subcommand>] [<name>] [<command>...]
# jr [<subcommand>] [<name>] [<command>...]
#
# DESCRIPTION
# Runs, lists, inspects, re-attaches to, and terminates named background
# jobs using 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
# run, -r, --run <name> <cmd>... Start a named job in the background
# list, -l, --list List all managed background jobs
# attach, -a, --attach <name> Re-attach interactively to a job
# kill, -k, --kill <name> Terminate a running background job
# logs, -o, --output <name> 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 screen is not installed
#
# EXAMPLE
# jobrunner run build make -j8
# jobrunner 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 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
# ╭──────────────────────────────────────────────────────────╮
# │ 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""[<subcommand>] [<name>] [<command>...]$c_rst"
echo
echo " Run and manage named background jobs backed by GNU screen."
echo
echo "$c_head""Subcommands:$c_rst"
echo " $c_cmd""run$c_rst $c_arg""<name> <cmd>...$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""<name>$c_rst Re-attach interactively to a job"
echo " $c_cmd""kill$c_rst $c_arg""<name>$c_rst Terminate a running background job"
echo " $c_cmd""logs$c_rst $c_arg""<name>$c_rst Print a job's output without attaching"
echo
echo "$c_head""Flags:$c_rst"
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""<name>$c_rst $c_dim""attach <name>$c_rst"
echo " $c_cmd""jobrunner$c_rst $c_arg""<name> <cmd>...$c_rst $c_dim""run <name> <cmd>...$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 not command -q screen
echo "$c_err""jobrunner:$c_rst 'screen' is required but was not found in PATH." >&2
return 127
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 | 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""<name> <command>...$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 | string replace -r '\t.*$' '')
echo "$c_err""jobrunner:$c_rst job '$c_arg$name$c_rst' is already running." >&2
return 1
end
command screen -d -m -S $name $task
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)
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)
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""<name>$c_rst" >&2
return 1
end
set -l name $argv[2]
if not contains -- $name (__jobrunner_sessions | string replace -r '\t.*$' '')
echo "$c_err""jobrunner:$c_rst no such job '$c_arg$name$c_rst'." >&2
return 1
end
# -x attaches to an already-attached session instead of failing.
command screen -x $name
case kill -k --kill
if test (count $argv) -ne 2
echo "$c_head""Usage:$c_rst $c_cmd""jobrunner kill$c_rst $c_arg""<name>$c_rst" >&2
return 1
end
set -l name $argv[2]
if not contains -- $name (__jobrunner_sessions | string replace -r '\t.*$' '')
echo "$c_err""jobrunner:$c_rst no such job '$c_arg$name$c_rst'." >&2
return 1
end
command screen -X -S $name quit
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""<name>$c_rst" >&2
return 1
end
set -l name $argv[2]
if not contains -- $name (__jobrunner_sessions | string replace -r '\t.*$' '')
echo "$c_err""jobrunner:$c_rst no such job '$c_arg$name$c_rst'." >&2
return 1
end
# 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 -l out (command cat $dump)
command rm -f $dump
# hardcopy pads the dump out to the full window height.
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
+28
View File
@@ -0,0 +1,28 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# CATEGORY
# 08-terminal-management
#
# DEPENDENCIES
# jobrunner
#
# SYNOPSIS
# jr [<subcommand>] [<name>] [<command>...]
#
# DESCRIPTION
# Shorthand for jobrunner. Accepts the same subcommands, flags, and
# shorthands, and inherits its completions.
#
# ARGUMENTS
# See jobrunner --help for the full argument reference.
#
# EXIT STATUS
# Same as jobrunner.
#
# EXAMPLE
# jr run build make -j8
# jr list
function jr --wraps jobrunner --description 'Shorthand for jobrunner'
jobrunner $argv
end