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
+1
View File
@@ -30,6 +30,7 @@ This config layers on top of the CachyOS base Fish configuration and adds:
- **Smart CLI wrappers** that prefer modern tools (`eza`, `bat`, `btop`, `dust`, `prettyping`) with graceful fallbacks
- **Auto Python venv** activation on directory change (direnv-aware)
- **Kitty terminal** deep integration for splits, tabs, and SSH
- **Named background jobs** — `jobrunner` (short: `jr`) starts, lists, inspects, re-attaches to, and kills detached tasks via GNU `screen`, so long-running work survives closing the shell
- **Optional session logging** — terminal scrollback, multiplexer panes (tmux/zellij), and AUR-helper output can be captured to `~/.terminal_history`; **off by default**, opt in when you want it (see the caution below and [Session Logging](#session-logging))
- **AI workflow** helpers for Claude and Antigravity session management
- **WakaTime** shell activity tracking
+57
View File
@@ -0,0 +1,57 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# Completions for the `jobrunner` background job manager.
# `jr` inherits these via `function jr --wraps jobrunner`.
# Offer running jobs as name<TAB>description pairs.
function __jobrunner_complete_jobs
for row in (__jobrunner_sessions)
set -l f (string split \t -- $row)
printf '%s\t%s job (PID %s)\n' $f[1] $f[3] $f[2]
end
end
set -l subcmds run list attach kill logs help
set -l needs_job "__fish_seen_subcommand_from attach kill logs -a --attach -k --kill -o --output"
# No file completions; jobs are named, not paths.
complete -c jobrunner -f
# Subcommands (only as the first argument).
complete -c jobrunner -n "not __fish_seen_subcommand_from $subcmds" \
-a run -d 'Start a named job in the background'
complete -c jobrunner -n "not __fish_seen_subcommand_from $subcmds" \
-a list -d 'List all managed background jobs'
complete -c jobrunner -n "not __fish_seen_subcommand_from $subcmds" \
-a attach -d 'Re-attach interactively to a job'
complete -c jobrunner -n "not __fish_seen_subcommand_from $subcmds" \
-a kill -d 'Terminate a running background job'
complete -c jobrunner -n "not __fish_seen_subcommand_from $subcmds" \
-a logs -d "Print a job's output without attaching"
complete -c jobrunner -n "not __fish_seen_subcommand_from $subcmds" \
-a help -d 'Show usage help'
# Flag forms.
complete -c jobrunner -n "not __fish_seen_subcommand_from $subcmds" \
-s r -l run -d 'Start a named job in the background'
complete -c jobrunner -n "not __fish_seen_subcommand_from $subcmds" \
-s l -l list -d 'List all managed background jobs'
complete -c jobrunner -n "not __fish_seen_subcommand_from $subcmds" \
-s a -l attach -d 'Re-attach interactively to a job'
complete -c jobrunner -n "not __fish_seen_subcommand_from $subcmds" \
-s k -l kill -d 'Terminate a running background job'
complete -c jobrunner -n "not __fish_seen_subcommand_from $subcmds" \
-s o -l output -d "Print a job's output without attaching"
complete -c jobrunner -s h -l help -d 'Show usage help'
# Running jobs, for the subcommands that take one.
complete -c jobrunner -n "$needs_job" -a '(__jobrunner_complete_jobs)'
# A bare job name attaches to it, so offer jobs in first position too.
complete -c jobrunner -n "not __fish_seen_subcommand_from $subcmds" \
-a '(__jobrunner_complete_jobs)'
# After `run <name>`, complete the command to execute.
complete -c jobrunner -n "__fish_seen_subcommand_from run -r --run; and test (count (commandline -opc)) -ge 3" \
-a '(__fish_complete_subcommand)'
+2
View File
@@ -125,6 +125,8 @@ split=### split
spwin=### spwin
detach=### detach
bkg=### bkg
jobrunner=### jobrunner
jr=### jr
ssh=### ssh
clipboard=## 5.9 Clipboard
copy-fn=### y
+65
View File
@@ -1482,6 +1482,71 @@ Add -i (interactive confirmation) to destructive commands:
Example:
# Rendered automatically by fish; not called directly.
### jobrunner
Synopsis: jobrunner [<subcommand>] [<name>] [<command>...]
jr [<subcommand>] [<name>] [<command>...]
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
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'`.
Example:
jobrunner run build make -j8
jobrunner backup rsync -a ./data remote:/backup/
jobrunner list
jobrunner logs build
jobrunner build
jobrunner kill build
**Dependencies:** `screen`, `__jobrunner_sessions`
**Used by:** `jr`
### jr
Synopsis: jr [<subcommand>] [<name>] [<command>...]
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
**Dependencies:** `jobrunner`
### split
Synopsis: split [-h | -v] [command...]
+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