From 674a50ea05ba0c6e10f3e5ae6c547527492903f6 Mon Sep 17 00:00:00 2001 From: rootiest Date: Sun, 2 Aug 2026 04:40:07 -0400 Subject: [PATCH] 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. --- README.md | 1 + completions/jobrunner.fish | 57 ++++++ docs/fish-config.index | 2 + docs/fish-config.md | 65 +++++++ functions/__jobrunner_sessions.fish | 43 +++++ functions/_fish_deps_catalog.fish | 10 +- functions/jobrunner.fish | 258 ++++++++++++++++++++++++++++ functions/jr.fish | 28 +++ 8 files changed, 459 insertions(+), 5 deletions(-) create mode 100644 completions/jobrunner.fish create mode 100644 functions/__jobrunner_sessions.fish create mode 100644 functions/jobrunner.fish create mode 100644 functions/jr.fish diff --git a/README.md b/README.md index 07653b5..4df41f5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/completions/jobrunner.fish b/completions/jobrunner.fish new file mode 100644 index 0000000..52a1acd --- /dev/null +++ b/completions/jobrunner.fish @@ -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 namedescription 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 `, 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)' diff --git a/docs/fish-config.index b/docs/fish-config.index index f248bdc..c9d2373 100644 --- a/docs/fish-config.index +++ b/docs/fish-config.index @@ -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 diff --git a/docs/fish-config.md b/docs/fish-config.md index efd8f82..93b8bc8 100644 --- a/docs/fish-config.md +++ b/docs/fish-config.md @@ -1482,6 +1482,71 @@ Add -i (interactive confirmation) to destructive commands: Example: # Rendered automatically by fish; not called directly. +### jobrunner + + Synopsis: jobrunner [] [] [...] + jr [] [] [...] + + 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 ... 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 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 [] [] [...] + + 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...] diff --git a/functions/__jobrunner_sessions.fish b/functions/__jobrunner_sessions.fish new file mode 100644 index 0000000..97c5de6 --- /dev/null +++ b/functions/__jobrunner_sessions.fish @@ -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: \t\t\t +# +# 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 ".". + set -l m (string match -r '^\s+(\d+)\.([^\t]+)\t(.*)$' -- $line) + or continue + + # Trailing fields are parenthesized: "()\t()", 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 diff --git a/functions/_fish_deps_catalog.fish b/functions/_fish_deps_catalog.fish index bc16485..09db70c 100644 --- a/functions/_fish_deps_catalog.fish +++ b/functions/_fish_deps_catalog.fish @@ -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 diff --git a/functions/jobrunner.fish b/functions/jobrunner.fish new file mode 100644 index 0000000..b8ed995 --- /dev/null +++ b/functions/jobrunner.fish @@ -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 [] [] [...] +# jr [] [] [...] +# +# 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 ... 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 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""[] [] [...]$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"" ...$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-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 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"" ...$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""$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""$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""$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 diff --git a/functions/jr.fish b/functions/jr.fish new file mode 100644 index 0000000..eb5fc5f --- /dev/null +++ b/functions/jr.fish @@ -0,0 +1,28 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# CATEGORY +# 08-terminal-management +# +# DEPENDENCIES +# jobrunner +# +# SYNOPSIS +# jr [] [] [...] +# +# 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