Glossary of Deliberate Oddities

say-hi’s shell code has three masters: bash 3.2 (macOS’s /bin/bash, the floor CI enforces), POSIX sh (dash/ash/busybox source parts of it), and fish (which parses common/paths.sh, common/aliases.sh, and settings.sh natively). Targets also split between GNU and BSD userlands. Each entry is a construct that looks odd until you know which master it serves.

Every entry carries a stable HI.NN code; a file references it with a # GLOSSARY: HI.NN tag — one code, or two joined with +, optional prose after — instead of re-explaining. The tag is mandatory in common/, config/, load.sh, and hi.sh. Tags point at codes, so an entry can be retitled without touching a tagged file; codes are never reused once retired. tests/lint/drift_test.sh fails the build if a tag names a code this file doesn’t define, or if an entry here is referenced by nothing. This file never ships (docs/ is not in $_HI_PAYLOAD).

Contents

HI.01 empty-array guard

${a[@]+"${a[@]}"} wherever an array may be empty under set -u: bash 3.2 treats expanding an empty array as a fatal “unbound variable”.

Exception - the index form. "${!a[@]}" is already empty-safe and must NOT get the guard: bash 3.2 reads ${!a[@]+...} as expanding to nothing, and bash 5 reads it as an indirect reference and errors. The lint table rejects the guarded index form.

HI.02 _hi_read_lines

mapfile/readarray are bash 4. _hi_read_lines <array-name> (common/core.sh) is the stand-in: a while read loop assigning through eval, keeping a last line without a trailing newline the way mapfile -t does. Use it as _hi_read_lines lines < <(cmd).

HI.03 parallel arrays

Associative arrays are bash 4 — on 3.2 the declaration alone is fatal. Where a map is needed: parallel indexed arrays sharing one index with a keys array (_hi_group_index in scripts/preview.sh), or "<key>=<value>" strings via _hi_kv_get/_hi_kv_set (tests/lib/fixtures.sh).

HI.04 dynamic-name assignment

bash 3.2 has no namerefs, so writing into a caller-named variable goes through eval for an array (_hi_read_lines) or printf -v for a single string, and reading one through ${!name} where only bash reads the file. Reading a caller’s local works through bash’s dynamic scoping — which cuts both ways: a helper that writes an out-var by name must not declare a local of the same name, or it writes into its own (_hi_setting_get’s locals are prefixed for that reason).

HI.05 printf -v out-var

out="$(fn)" forks a subshell per call; fn outvar with printf -v "$outvar" doesn’t. Used on hot paths (_hi_git_prompt’s optional out-var, _hi_repeat, _hi_prompt_end), usually as [outvar] with _hi_out as the tail; zsh’s printf takes -v too, so common/zsh.zsh’s precmds use the same form.

Never printf -v x '' (a bare empty format, zero arguments) to clear a variable: bash 3.2 skips the assignment outright when there is nothing to format, leaving $x untouched. printf -v x '%s' '' clears it everywhere.

HI.06 source guard

[[ "${BASH_SOURCE[0]}" == "$0" ]] || return 0 above a script’s imperative tail: sourcing the file defines its functions and stops there, which is how the test suites reach the functions without running an install/bump/render. hi.sh, scripts/install.sh, scripts/doctor.sh, scripts/preview.sh, packaging/bump.sh, packaging/mkpkg.sh, and packaging/mkrepo.sh carry it.

HI.07 toggle defaulting

fish has no ${X:-0}, and it sources aliases.sh/paths.sh/settings.sh natively — so every _HI_DISABLE_* toggle is read bare, and a bare read of an unset variable is fatal under bash’s set -u. The toggles must always exist: common/core.sh defaults the _HI_TOGGLES list (defaulted, never assigned, so settings.sh and paths.sh’s gate still win), common/config.fish mirrors it with set -q X; or set -gx X 0, and hi.sh’s _hi_fallback_rc emits export X=0 lines from the same list for bash-less targets. The two opt-ins, _HI_TOOL_ALIASES and _HI_SUDO_ALIAS, sit outside the list: common/aliases.sh’s backstop line defaults them to 0, and every reader compares against 1, so unset is off.

HI.08 sed tempfile rewrite

Never sed -i: its in-place flag takes an argument on BSD and not on GNU. Rewrites go sed > tmpfile then write back — see HI.09 for why with cat.

HI.09 cat-over-mv

A tempfile goes back over an existing file through the existing inode: cat "$tmp" > "$target"; rm -f "$tmp" (_hi_write_back in scripts/lib.sh; rewrite in packaging/stamp.sh is the boundary-forced copy). mv would transplant mktemp’s 0600 mode onto the target and sever any hardlink/ACL — a dotfile manager’s hardlinked ~/.bashrc must see the new content. Non-atomicity is fine for single-user rc files; common/targets.sh’s cache swap keeps mv for atomicity over a file it owns, and the payload stager keeps it over a stage nothing links to (HI.39).

_hi_write_back also reads the target’s mode before the cat and chmods it back after: a Windows Git Bash run lost a 604 mode across this exact rewrite despite going through the existing inode. The stat -c/stat -f fallback is the GNU/BSD split common/config.fish’s mtime probe already uses.

HI.10 strftime %e over %-e

date +%-e (no-padding) is a GNU extension; BSD strftime prints the literal characters. %e is the portable day-of-month.

HI.12 bytes vs columns

${#var} counts bytes, not display columns; multibyte characters inflate it, and a banner padded by it comes out narrow. Width math around user-visible strings computes column counts explicitly (changes_w in common/header.sh, _hi_visible_len in scripts/table.sh).

HI.13 command -v fallthrough

export _HI_LS_BIN="$(command -v eza || command -v exa || command -v ls)" in common/aliases.sh (shown without its [ -z ] && guard and the alias-clearing prefix below), with the aliases built on the result: resolved at source time, valid in sh, bash, zsh and fish, and ending in a binary every target has, so no alias points at a missing one. The chains run only under _HI_TOOL_ALIASES=1: off, no alias reads them, and their forks are skipped.

A second, narrower chain over the same family delivers flags only to the tier that parses them: $_HI_BAT_BIN is bat || batcat where $_HI_CAT_BIN is bat || batcat || ccat || cat, so bat-syntax options attach behind [ -n "$_HI_BAT_BIN" ] && alias ... || true and ccat and coreutils cat get the bare binary.

Every chain runs before any alias exists, the overlay’s aliases.sh included (it is sourced last): in zsh and dash command -v name returns an alias’s definition once one exists, so an overlay alias cat=... ahead of the chains would leave $_HI_CAT_BIN holding the alias body. Ordering alone does not cover an alias that was there before hi - a target’s alias ls='ls --color=auto', or hi’s own vim alias when an interactive shell re-sources its rc - so each $( ) opens with type unalias >/dev/null 2>&1 && unalias -a || true &&, clearing aliases in that subshell only (fish has no unalias and its command -v never reports one). bash and zsh skip those forks: with common/core.sh loaded, each chain runs through _hi_path_lookup, a walk of $PATH that sees no alias or function, and the $( ) line runs only where that function is not defined. tests/config/alias_fallthrough_test.sh is the regression test.

HI.14 _hi_on_exit

bash’s trap "$cmd" EXIT fires at real shell exit wherever it was set. zsh’s does not: an EXIT trap (TRAPEXIT included) set inside a function fires when that function returns — and _hi_on_exit (common/core.sh) is itself a function, so every caller’s cleanup fired at once, silently. add-zsh-hook’s zshexit array is the one mechanism exempt from that scoping (zsh’s own completion system uses it for the same reason), so the zsh arm autoloads it and registers a uniquely-named function there instead of touching trap/TRAPEXIT. Outside a subshell, _hi_on_exit is the only way shared code registers a cleanup trap.

HI.15 strict-mode bracketing

Files sourced into an interactive shell (common/core.sh, hi.sh, common/git_prompt.sh, common/env_prompt.sh, …) set set -euo pipefail at the top and disable it at the end of their own code: left on, any later non-zero status or unset variable kills the user’s session. The common/ files end with _hi_opts_restore, which gives an interactive shell back the -e, -u, and pipefail it had before the file loaded; a script keeps them off. A user’s file (settings.sh) is sourced with strict mode off, so one failing line cannot stop a shell starting. common/bash.sh never enables it at all. The bootloader and fallback rc do the same on targets — forgetting it there breaks hi <target> <command> outright.

HI.16 no-fork reads

On per-prompt/per-startup paths, builtins over binaries: read -r x < file instead of $(cat file), ${target%/*} instead of $(dirname ...), ${row%%$'\t'*} instead of | cut -f1. A few forks per prompt is the whole latency budget.

HI.17 base64 armor

The payload is armored with base64 first: pure ASCII transport encoding (no crypto), shipped on strictly more hosts than openssl — coreutils, busybox, macOS/BSD, and Git Bash. openssl base64 stands in where base64 is missing, on either end: stock OpenBSD ships LibreSSL and no base64(1). Decode tries GNU/busybox -d first, then old BSD/macOS -D; the failed flag parse consumes no stdin, so the fallback still sees the whole stream. tr runs first because GNU base64 -d tolerates newlines but not spaces, and a transport that folds newlines into spaces would otherwise break it. openssl gets every space and newline stripped and -A instead: LibreSSL’s line mode silently garbles one long line, which is what macOS’s base64 writes. The decoder is picked by command -v, not chained after -D: a -d that failed on a corrupt stream has read it, leaving openssl nothing to decode. openssl exits 0 on bad input, so a corrupt stream surfaces at tar. $_HI_UNARMOR only ever runs inside the sh bootloader — the login shell never parses its braces (fish couldn’t).

HI.18 sh -c wrapping

Every command hi sends meets the target’s login shell first, which may be fish — and fish parses neither x=1 nor { ...; } nor || as sh does. Wrapping everything in sh -c '...' is the transport’s job, not per-site care (unwrapped, the boot probe’s if ...; then ... fi does not even parse on a fish-login host). Quoting is single-quote-and-escape rather than printf %q, which backslash-escapes every space — unreadable in the code and in an ssh -v log, and one more thing for fish to differ about.

HI.19 stdin transport

The bootloader travels over stdin of the first of two ssh calls multiplexed on one connection (one authentication), never as an argument: Linux caps a single argv entry at 128KB (MAX_ARG_STRLEN) however large ARG_MAX is — a hard ceiling on payload growth that stdin does not have. Two calls because the second’s stdin belongs to the interactive session. The script goes as plain text and is cat into place; only the streams inside it are armored — armoring the whole script spent a third of every session’s bytes re-encoding ASCII.

The write doubles as the probe (_hi_boot_probe), and _hi_boot_why reads its status: the directory came back and the session runs; sh ran but found neither base64 nor openssl (exit 64) or nowhere to mktemp (65), and hi names the missing piece and hands over the host’s own session; something that was not sh answered — a ForceCommand or a command= key, told by an exit of 0 or any stdout, neither of which a missing sh produces — and hi says so and hands over the same; or nothing ran at all (stock Windows OpenSSH), and the session falls through to the PowerShell branch rather than half-landing.

HI.20 fallback rc

The no-bash target’s rc is consumed by sh, zsh and fish (_say_hi’s fish -C branch), so every line must be valid in all three — export NAME=value and [ -f x ] && . x are; anything shell-specific is appended by that shell’s own arm. Toggle defaults come first so the files after them still win. _HI_REMOTE_SESSION=1 is exported because this path never reaches load.sh. settings.sh keeps its [ -f ] guard because a bare . on a missing file abandons the rest of the file in ash/dash. _HI_CONFIG_DIR points at the target’s own config/, where the shipped overlay was unpacked.

HI.21 baked prompt

The bash-less tiers’ PS1 is baked on the client — colors resolved once, the username read once — because busybox ash does not run command substitution inside PS1 at all: handed a live $( ), it prints the substitution’s text.

HI.22 TERM fallback probe

ssh forwards the client TERM verbatim, and a TERM the target has no terminfo entry for (ghostty’s xterm-ghostty, kitty’s xterm-kitty) breaks clear/backspace before hi even matters. The bootloader skips the probe for ubiquitous names; anything else must be found in a terminfo tree — plain dirs and the BSD/macOS single-hex-char layout both checked — or is swapped for xterm-256color. Always on: there is no setting to keep a TERM the target cannot render.

HI.23 bash –rcfile -i

bash --rcfile X -i needs both flags, in that order: without -i bash decides it isn’t interactive and ignores the rcfile (hi <target> <cmd> doing nothing from a script or cron), and -i must come after --rcfile because bash’s long-option pass ends at the first short option. fish differs twice: exit inside a sourced file only unwinds the source, so the fish arm feeds the rc to -C instead — and exit inside -C does not stop fish starting its interactive reader when stdin is a tty, so the command from hi <target> <cmd> rides -c, which fish runs after -C and then exits from. sh and zsh get the command appended to their rc (_hi_command_append).

HI.25 session-shell ranking

The session shell is the user’s login shell when hi styles it, else the first installed of the names hi styles; bash is the floor because load.sh only runs where bash exists. The tail is not a literal: _hi_session_shell walks common/core.sh’s $_HI_SHELL_TREE (fish zsh bash dash ash sh) and drops the tiers that need bash to be missing, leaving fish > zsh > bash; hi.sh’s $_HI_SHELL_LADDER is the same tree with bash removed. One list, two consumers.

login leads because a ranking that leads with fish hands it to anyone whose box has it, so a user whose login shell is zsh-with-oh-my-zsh would never see their own setup.

HI.26 completion probe knobs

targets.sh runs on every TAB after hi and a space — say-hi’s most latency-sensitive path and its slowest (every backend but ssh is a subprocess, one per CLI on $PATH). Two internal knobs keep it honest - environment variables the suites and the bench set, not settings.

_HI_PROBE_TIMEOUT — seconds a backend CLI gets (default 2; needs GNU or busybox timeout; shared with common/core.sh’s _hi_probe). It bounds the whole sweep: backends start together and are read back in roster order, so four wedged daemons cost one ceiling. A host with no writable scratch directory falls back to the in-turn sweep — slow, not wrong. The cap is a SIGTERM with a SIGKILL 200ms behind it (timeout -k 0.2): a CLI may defer a TERM while it finishes something — rootless podman does for the whole of its runtime setup, which on a fresh $HOME can outlast the cap — and without the KILL the ceiling is a request. BusyBox before 1.35 refuses -k, and before 1.30 takes the cap only as -t SECS, so the first probe tries each form and keeps the one that runs; kubectl also gets --request-timeout, its own bound where there is no timeout at all (stock macOS).

_HI_TARGETS_TTL — seconds a result is reused (default 5, 0 disables); a just-started container may not appear until it expires. Nothing invalidates it but the clock. Two offset windows stack — the file cache in targets.sh stamps when it was written, the in-shell memo in common/bash.sh/common/zsh.zsh when that shell last read the file — so worst-case staleness is close to twice the TTL. Only _HI_TARGETS_TTL=0 turns both off.

Past the TTL the file is not discarded at once. Until the copy is ten minutes old (stale_for in targets.sh) a TAB is answered from it, immediately, and the replacing sweep runs behind it - no TAB inside a working session waits on a daemon. A lock directory beside the cache allows one refresh at a time, taken over if it outlives any real sweep. Older than that, the sweep is waited on again, like a first TAB. _HI_TARGETS_TTL=0 skips all of this.

ble.sh’s as-you-type completion calls bash’s completion function on every keystroke; with auto in its comp_type it gets the names the shell already holds, whatever their age, and never a sweep of its own.

HI.29 apostrophes in substitution comments

bash 3.2 scans a $( ... ) command substitution with a simple quote matcher: a comment line inside one containing a lone ' reads as an unterminated string, and the whole file dies at parse time. The same matcher ends the substitution at a bare case pattern’s ), a syntax error found only when the line runs. bash 4+ parses substitutions recursively, which is why this only surfaces on macOS. Keep comments inside $( ) apostrophe-free, or hoist them above the assignment, and hoist a case into a function. The lint greps cannot see this one; tests/targets/ssh_test.sh runs bash -n over every file in a real 3.2 container.

HI.30 indirect invocation

Test suites hand their case functions to _hi_check/_hi_case/_hi_par_case as "$@", or register them as trap hooks, so nothing in the file calls them by name. shellcheck reads that as dead code (SC2329), so every suite carries a file-level # shellcheck disable=SC2329.

HI.31 porcelain branch.oid

git status --porcelain=v2 --branch already carries HEAD’s sha on its # branch.oid line, so the detached-HEAD label reads it out of the stream the prompt is already parsing instead of forking git rev-parse. The rev-parse beneath it is a fallback for a porcelain stream too old to carry that header.

HI.32 starship deference

_HI_PROMPT_TOOL hands the prompt to a prompt program - starship, oh-my-posh, powerline-go, powerlevel10k, oh-my-zsh, oh-my-bash, bash-it, or tide - keeping hi’s header and aliases. It is a list, each shell taking the first entry that fits it and is present; hi is hi’s own prompt and ends the walk. A <shell>:<program> entry is walked first, by that shell alone. With no plain entry, or unset, the rest is the whole roster (common/core.sh’s _HI_PROMPT_TABLE, one row per program - the shells it fits, how it is found, the overlay member its home config rides as - frameworks ahead of the programs that fit every shell; _hi_prompt_row answers to a program’s name or a member’s), so a prompt already in use at home is the default rather than something hi draws over; hi is the opt-in back, and the one entry that unhooks a program the rc already started (bash’s PROMPT_COMMAND, zsh’s precmd_functions, fish’s right and mode prompts) - an unset list, or one that ran out, leaves that program drawing. A target never looks for itself: hi.sh’s _hi_prompt_list resolves the list on the client - the setting, else what this machine has installed - and ships it as a session variable (HI.47), so an unset setting on a target is hi’s prompt, not whatever the box happens to carry.

common/core.sh’s _hi_prompt_tool <shell> is the single predicate: starship, oh-my-posh, and powerline-go count where the binary is, and a framework through the shell’s own _hi_prompt_fw - loaded by the rc already, or, on a target only, installed where its README puts it and with home’s file to draw. At home the rc is the user’s whole answer, so an installed-but-unloaded framework (a distro’s powerlevel10k package nobody adopted) is never started there. common/config.fish mirrors the rule since fish cannot call it. Each program is started the way its README wires it: starship and oh-my-posh by evaling init <shell>; powerline-go from PROMPT_COMMAND, a precmd, or fish_prompt; powerlevel10k and oh-my-zsh’s libraries and oh-my-bash sourced when the rc did not (oh-my-zsh’s libraries alone, oh-my-bash without plugins, and hi’s aliases put back over theirs); bash-it the same way, BASH_IT_THEME pointed straight at home’s packed theme file when the rc had not loaded it already - its own loader takes a literal path there, so unlike oh-my-bash this needs no separate theme-file step; tide by leaving its autoloaded fish_prompt alone. Each framework’s loader is a file of its own, common/fw_<name>.<ext>, and hi.sh’s _hi_payload_excl cuts the ones a target is not handed from the payload, as it cuts a shadowed default (HI.41); _hi_prompt_fw asks for the file first, so a framework whose loader did not ride is passed over for the next in the list. The git and environment segments stay in the rcs: they draw with no fork.

Unhooking a program the rc already started (_HI_PROMPT_TOOL=hi) means different surfaces per program: bash’s PROMPT_COMMAND string for starship, oh-my-posh, and powerline-go’s own hooks, but bash-it’s real hook lives in the precmd_functions array bash-preexec keeps (most bundled themes call its safe_append_prompt_command rather than assigning PROMPT_COMMAND directly), so common/bash.sh clears that array too - bash has no zsh-style :# array filter, so a rebuild loop rather than one substitution.

Home’s config rides the overlay (HI.41) and applies on a target only (_HI_REMOTE_SESSION=1), over whatever the target has: $STARSHIP_CONFIG / $POSH_CONFIG (and $POSH_THEME, which older oh-my-posh releases read) from the generated wiring (HI.62), p10k.zsh sourced after powerlevel10k, the oh-my-zsh, oh-my-bash, or bash-it theme file the home rc names sourced over the framework, and tide’s tide_* universal variables exported as globals - exported because tide renders in a background fish -c that must see them over the target’s own. Absent every program, the prompt is hi’s, silently.

A prompt with no hand-over stays the user’s where hi can tell one is drawing: a framework’s marker (liquidprompt, bash-git-prompt, spaceship, pure, a promptinit theme), a fish_prompt that is not fish’s own, and - at home only - a $PS1 that _hi_ps1_stock does not know. That predicate (common/bash.sh and common/zsh.zsh, one list each) answers yes to an unset prompt, to hi’s own from an earlier load (so a re-sourced rc redraws, HI.60), and to each default a shell or a distro’s stock rc leaves; a default missing from it costs that distro’s users hi’s prompt at home until it is added, and hi in the list is their way back. It is never asked on a target, whose rc ran before hi’s: the list cannot cover every box a session reaches, and an unset setting there stays hi’s prompt.

HI.33 derived tree location

$_HI_HOME is the directory containing say-hi. Every file that needs the tree derives it from its own path rather than defaulting to $HOME: the default was right for a standard install and wrong everywhere else, and when wrong it silently read another tree (the platform e2e jobs sourced a tree under /Users/runner that was never there). hi.sh and the standalone scripts ask only when $_HI_HOME is unset, so an outer export still wins and costs no fork; scripts/install.sh and packaging/lib.sh always derive from their own resolved path - an install acts on the tree it sits in.

common/core.sh owns the answer; everything that merely needs the tree reaches core.sh through its own path. The files that derive have nothing above them to ask through:

where how
common/core.sh ${BASH_SOURCE[0]}, then cd -P ../.. && pwd; answers for every file sourced through it
hi.sh, scripts/install.sh, packaging/lib.sh the same behind a readlink walk, so ~/.local/bin/hi (a package’s /usr/bin/hi) answers the tree it links to. Three copies: each must resolve itself before it can source anything
load.sh, tests/test_runner.sh ${BASH_SOURCE[0]} - entry points that export for children
tests/test_lib.sh ${BASH_SOURCE[0]} only, $_HI_HOME or not - the harness sits in the tree it tests, and _hi_host_tree_check warns when $_HI_ROOT names another
scripts/doctor.sh, preview.sh, update.sh, add_tag.sh, add_package.sh ${BASH_SOURCE[0]}, then $_HI_HOME if set - the standalone-entry form below
zsh (common/zsh.zsh, and common/core.sh reached through it) ${(%):-%x} with zsh’s :A:h modifiers; bash cannot parse %x, so core.sh’s arm is eval‘d
fish (common/config.fish) sh -c 'cd -P "$1/../.." && pwd' - a builtin-only substitution would move the caller’s cwd, and fish’s pwd is logical where every other dialect here is physical
common/bash.sh $_HI_HOME first, its own path as the fallback - hi.sh’s preamble and install.sh’s rc line both set it before this file is sourced

The standalone-entry form, and why $_HI_HOME wins in it. A script run on its own derives from ${BASH_SOURCE[0]} only as the fallback:

_hi_d="${BASH_SOURCE[0]}"
case "$_hi_d" in */*) _hi_d="${_hi_d%/*}/.." ;; *) _hi_d=".." ;; esac
[ -z "${_HI_HOME:-}" ] || _hi_d="$_HI_HOME/say-hi"

With $_HI_HOME set, everything comes from there, core.sh included: reaching core.sh through the script’s own path while $_HI_ROOT came from $_HI_HOME runs two trees in one process, silently.

No file falls back silently: hi.sh prints set _HI_HOME to the directory that holds it and exits when the derived path holds no tree. A target needs no such rule — a session’s tree is the one hi just unpacked, its $_HI_HOME exported by the script that unpacked it; a say-hi the target already has is that machine’s own install, and a session never reads it.

tests/lint/drift_test.sh’s lint_home_default greps the tree, .md included, for the retired $HOME default — a doc teaching it is what a packager reads.

HI.34 test suite preamble

Every suite under tests/ opens with the same four lines — four separate mechanisms, not boilerplate. Two kinds differ, on purpose: a split suite (*_ci_test.sh, doctor_*_test.sh) sources its parent suite in place of the harness, which the parent then sources once; and a suite with no function it reaches only indirectly (the lint suites, docker’s and podman’s) drops the SC2329 line:

# GLOSSARY: HI.30 + HI.34
# shellcheck disable=SC2329
set -euo pipefail

# shellcheck source=../test_lib.sh
source "${_HI_TEST_LIB:-${BASH_SOURCE[0]%/*}/../test_lib.sh}"

$_HI_TEST_LIB is exported by common/paths.sh, so under test_runner.sh the harness is found through the tree the runner resolved. The ${...:-} tail is the fallback for running a suite directly; its ../ depth is the suite’s distance from tests/, so a suite that moves has to have it re-counted.

test_lib.sh sources common/core.sh itself; a suite sources the harness and never core.sh, or core.sh initialises twice, the second time after the harness has moved $XDG_CONFIG_HOME into the scratch dir. The # shellcheck source= line is a directive the linter follows, and # shellcheck disable=SC2329 is HI.30. Both stay verbatim above their statement.

HI.35 payload comment and whitespace strip

Every tree file scripts/pack.sh’s $_HI_STRIP_NAMES matches — the shell files and the data files under config/ — and every overlay member whose dialect ($_HI_DIALECTS) has <strip> 1, such as vim/vimrc and tmux/tmux.conf, whose prose headers document the installed copies, is comment-stripped by _hi_strip_awk on its way into the payload or overlay; about 40% of the shipped shell is comment. A member’s comment leader is its dialect’s, handed to the stripper as c= ahead of the file: vimrc’s ", init.el’s ;, and lua’s --, beside #. Lua’s --[[ block form is deliberately not one: the strip is line-wise, so a block opener would go and its body stay — which is why the shipped nvim/init.lua uses line comments only. bench_payload_readme_badge checks README’s badge against the result, through packaging/stamp_badge.sh --check.

Blank lines and leading indentation go the same way: no dialect the payload carries reads either, and the indentation alone is 3% of the payload. Both are trimmed under the heredoc rule below, so a body a target reads as data keeps its shape (<<- still strips its tabs, on the target). A line continuing a word\ keeps its indentation too: there it is the only separator, and "a:b\⏎ c:d" stripped would read a:bc:d. Trailing whitespace is not handled: the lint forbids it in the tree already.

Two rules keep it safe. Full-line comments only: an inline # cannot be told from ${x#y}, $#, or a # in a string without a real parser. Never inside a heredoc: those bodies are data the target reads, one of them hi --help. The comment test runs before the heredoc-open test: a comment mentioning <<WORD would otherwise open a heredoc that never closes and silently stop stripping the rest of the file.

tests/hi/payload_test.sh pins the rest: no full-line comment survives outside hi.sh’s heredocs, every code line survives but for its own indentation, nothing blank or indented survives outside a heredoc while an indented heredoc body does, the result still parses, and hi.sh keeps its exec bit (HI.39).

HI.37 zsh pattern-in-variable

_hi_ssh_pattern_hit (common/core.sh) matches a name against Host/Match host glob patterns from ~/.ssh/config. * and ? mean the same in ssh’s syntax as in a case pattern; the difficulty is trying each pattern in a way that survives both bash and zsh.

The tokens are peeled off by parameter expansion, never for pat in $patterns: zsh does not word-split an unquoted variable, so that loop never iterates, and bash also pathname-expands it, so a bare * — the commonest Host line — becomes the cwd’s file list. Then zsh does not treat * in a variable’s value as a wildcard unless GLOB_SUBST is set — a case that never matches. ${~pat} turns it on for that one substitution rather than the whole function; bash cannot parse it, so the zsh arm is eval‘d behind $ZSH_VERSION.

A !-prefixed token (ssh’s per-pattern negation) is not honored as exclusion — it survives as a literal pattern nothing is ever named, so it is inert rather than wrong; the cost, a wrongly-colored excluded host, is cosmetic.

HI.38 split tar and gzip

_hi_tar_gz (hi.sh) runs tar -c -f - | gzip -9 -n rather than tar -c -z -f -. The two userlands pad differently, and only one pads something that survives compression: GNU tar rounds the uncompressed archive up to the 10240-byte blocking factor and then gzips it, so its trailing NULs cost about thirty bytes; bsdtar — macOS’s /usr/bin/tar — pads the compressed output stream, appending raw NULs after the gzip member, so a one-step payload built on a BSD client is a multiple of 10240: about 27% waste on a stock payload and a flat 54× on a two-file overlay (189 B against 10240). Split, the steps agree with GNU tar to within a few bytes under both userlands and are byte-stable run to run. -9, since the cache pays for the slower pass once and every connect sends the result.

${PIPESTATUS[@]}, not $?: hi.sh turns pipefail back off for interactive sourcing, so a failing tar would otherwise hide behind a successful gzip and ship a truncated payload — both halves are checked.

A client with no gzip falls back to tar -c -z -f -, which is a working payload on exactly one userland: libarchive’s tar (macOS’s /usr/bin/tar) compresses in-process, padded as above, while GNU’s and OpenBSD’s implement -z by exec’ing gzip(1) off $PATH and have nothing left to try. So the fallback is not “no gzip is survivable” — it is “a tar that compresses on its own is”. _hi_can_gzip asks which one this is (free where gzip is present, else one real tar -c -z of hi.sh — /dev/null is not reliably stat’able under Git Bash), _hi_require_packer refuses by name in both _say_hi and _say_hi_container rather than letting the target receive an empty archive, and hi --doctor reports the same three verdicts.

Every tar in hi.sh, client and target side, takes dash-style options: OpenBSD’s tar reads each word after an old-style cf <file> as a member name, so tar cf - -C dir archives a file called -C there.

HI.39 payload staging

_hi_payload_tar (scripts/pack.sh) ships the tree, comment-stripped (HI.35), through _hi_stage_tar, which the overlay shares; a session’s own _hi_payload_tar stages nothing (HI.66). What ships never depends on a toggle: $_HI_PAYLOAD is whole directories, every toggle is read where it applies, and a session that switched something off carries the file and leaves it alone (a per-toggle trim would save about a kilobyte at the cost of a cache key, a second table, and an exclusion list).

Staged, in a subshell, under a trap. The strip rewrites files and the tree is not hi’s to touch, so it is copied to a mktemp -d stage through an intermediate tar file (-h resolves symlinks) — a tar | tar pipe ends in EPIPE when the reader stops before a GNU writer’s record padding. The subshell lets cleanup be an EXIT trap rather than an rm on each way out, so a ^C mid-build leaves nothing in the client’s tmp. INT and TERM are trapped explicitly to exit, since a signal that kills the subshell outright never reaches the EXIT trap; set in the function’s own shell, the trap would replace the one _hi installed for its error log.

One find -exec awk +, and no rename. The stripper buffers each file and writes it back over itself in END, once every file in the batch has been read. The shape before it put each stripped copy in <file>.strip and renamed it back, which cost one mv a file — 40 in a hi --doctor, which stages twice, and the largest external cost that command had — and renaming over a file a runner still held open broke the Windows jobs. Writing in place leaves every mode alone too, so the exec bit hi.sh needs for the relay survives with nothing to note and restore; either way nothing links to a stage just unpacked, so HI.09’s four-process cat buys nothing here.

HI.40 hand-rolled sh quoting

_hi_shquote (hi.sh) turns a value into one single-quoted sh word by walking it with prefix/suffix removal rather than the obvious ${2//\'/\'\\\'\'}: bash 3.2 — the floor, and the bash macOS ships — leaves the quoting of the replacement word in the result, so that spelling emits a word no sh can parse. Prefix and suffix removal answer the same on every bash.

Everything hi.sh bakes into a script for the target is text the target’s shell will parse, and some of it is data: $DOMAIN off argv, $_HI_TARGET_TAG out of a free-text # Tags: comment, $_HI_RELEASE off git describe --dirty. An unescaped $, quote, or backtick in any of them breaks the bootloader’s parse and lets the target run a command substitution it should not. _hi_ssh_sh quotes its sh -c word through the same function, so the transports cannot drift into two dialects, and _hi_env_each hands its per-transport format values already quoted (%s=%s, never %s="%s"), so quoting is one decision rather than one per transport.

HI.41 overlay stream

The user’s config overlay ($_HI_OVERLAY_FILES in scripts/pack.sh) lives outside the tree, so it travels as a second, much smaller archive rather than inside the payload. It unpacks into the tree’s config/, over the defaults it shadows (which the payload already left out), and $_HI_CONFIG_DIR is that directory: one place a session reads config from. hi’s own aliases are common/aliases.sh, so the user’s aliases.sh shares config/ with nothing; common/aliases.sh still refuses to source a $_HI_CONFIG_DIR/aliases.sh that is itself. It is omitted when there is nothing to send.

The prompt programs’ configs, eza’s eza/theme.yml, bat’s bat/config, rg’s ripgreprc, fzf’s fzfrc, lazygit’s lazygit/config.yml, and readline’s inputrc ride it so a tool’s config on every target is the one in force at home: _hi_overlay_src packs the overlay’s copy when there is one, else the file the tool itself reads on the client (HI.61’s order; a prompt program’s only when _hi_prompt_list names it), so there is one copy to edit and none to drift. The generated wiring (HI.62) points each tool’s own variable ($STARSHIP_CONFIG, $EZA_CONFIG_DIR - the directory, since eza fixes the file name - $INPUTRC, …) at the overlay on a target only, and the shell files source or read the frameworks’ (HI.32). The stager keeps nothing of fish’s universal variables but the tide_ lines, since set -U holds whatever a user ever put there.

colors and packages ride it because the tree copy is a default, and common/paths.sh points $_HI_COLORS and $_HI_PACKAGES at the overlay’s when there is one. Left out of the stream, that guard could only fire on the client — an override working locally and silently reverting on every target, the asymmetry paths_test.sh’s guard/roster pin catches one layer up. The editor rcs, tmux/tmux.conf, screenrc, and the micro/ and zellij/ files ride with the wiring line that names them (HI.57).

That cascade is wholesale, so a member that shadows a tree default makes the default dead weight: a connect hands _hi_payload_excl its member list, and _hi_payload_tar drops those files ($_HI_OVERLAY_SHADOWS; never aliases.sh, which is additive) from the stage, cached under a key of its own. With _HI_DISABLE_HEADER on (read as HI.64’s _hi_toggle_on reads it), common/header.sh and config/packages are dropped too, about 11 KB armored: no target draws a header, and common/paths.sh there reads a missing header.sh as the header off, whatever the target’s settings.sh says. Dropped from the stage rather than with tar’s --exclude, which OpenBSD’s tar lacks. Only a caller holding the list cuts anything: _hi_wire_bytes has none and measures the stock tree (HI.44), and the container arm, where the two archives travel separately, sends the defaults after all when the overlay’s copy fails. A file still under a pre-1.0 name ($_HI_OVERLAY_RENAMES) is not a member, so it cuts nothing and the default it no longer overrides keeps riding. The target’s aliases and $VIMINIT ask for the rc file as well as the binary, so a box whose editor got no config keeps its own rather than an alias to a missing file.

ssh_tags is the one member with no file behind it on the client: _hi_ssh_tags_file cuts the # Tags: lines and the Host/Match host line under each out of ~/.ssh/config into the runtime dir, in ssh_config’s own shape, so _hi_ssh_host_tag on a relaying box walks it with the walker it already has - after its own config, and only in a remote session. Only tagged blocks ride, so an untagged block that would have answered first at home is not there to: a name both it and a later tagged wildcard match reads as tagged on the second hop.

HI.43 container target grammar

A container target may name what to run in as well as where: pod/container for kube, alloc/task for nomad — one spelling for both, since a task and a container are the same idea here, and a suffix rather than a flag so completion can offer the pairs (_hi_outer / _hi_inner in hi.sh). Only those two split: a container name on any of the docker-compatible family (HI.51) is taken whole, having no inner unit and / being legal in it.

kube adds [[context:]namespace:]pod[/container] (_hi_kube_split). : is the separator because no ssh host, container name, or allocation id may carry one, so a prefixed name can only mean a pod; no prefix means whatever kubectl points at, and common/targets.sh’s list_kube emits the same spelling for pods outside the current namespace. A multi-container pod resolves on the pod half; kubectl checks the container half when the session runs and fails loudly on a missing name — better than declining silently and falling through to ssh.

docker and podman also answer to a compose service name (_hi_compose_container) when exactly one running container carries that label. Ambiguous (two projects, same service) and absent both fail rather than guess — a wrong guess lands a session in someone else’s container — and the lookup runs only when the literal name does not resolve, so the common case pays one inspect. nerdctl and finch are left out as unverified: a .Label template or label= filter one of them rejects would fail the lookup rather than decline it.

HI.44 wire size token

The connect line prints the size of the script the session sent, but the script cannot know its own size while being assembled. _say_hi (hi.sh) builds it with $_HI_SIZE_TOKEN (@@SIZE@@, wider than any figure) standing in, measures ${#script}, and substitutes the human figure back — honest to a few bytes, since the streams inside are already armored and the script goes over the wire as it stands. _hi_wire_bytes — what hi --doctor and the README badge quote — assembles the same script through the same _hi_remote_script, from the same payload cache _say_hi reads, rather than summing the armored streams: summing skips the boilerplate around them and reads ~6KB low, and a badge has to show the number the user sees. No overlay is counted, since which files ride is a question about a target - and none of the tree defaults one would shadow is cut (HI.41), so the figure is the stock tree’s on any client.

HI.46 session rc directory

load.sh’s _hi_session_rc_setup writes one rc per shell into a mktemp -d of hi’s own and exports $_HI_SESSION_RC at it. The shell a user types at is not the one hi.sh starts: bash --rcfile hi.bashrc starts the bootloader, which sources load.sh and calls load(), which starts the session shell. A bare $shell -i there would read the target’s ~/.bashrc, so the session shell is pointed at hi’s own rc and the target’s rc files are never written.

Each generated rc sources the target’s own first (~/.bashrc, ~/.zshrc, and ~/.zshenv — ZDOTDIR moves all of zsh’s startup files, not just .zshrc), then hi’s on top, so the host’s configuration still applies underneath. A ~/.zshenv may set ZDOTDIR itself (a ~/.config/zsh layout), which would have zsh read that directory’s .zshrc and never hi’s, so the .zshenv shim runs it with ZDOTDIR unset, keeps what it chose, and points ZDOTDIR back at hi’s directory; the .zshrc then sources the target’s from there, under that ZDOTDIR.

Three variables are exported; which shell needs which is the design:

shell reached by inherited by a nested shell?
zsh $ZDOTDIR yes — free
sh, dash, ash $ENV yes — free, interactive shells
bash --rcfile no — needs a wrapper
fish -C 'source …' no — needs a wrapper

$ZDOTDIR and $ENV reach any zsh or POSIX shell started inside the session, however it was started, including by something that is not a shell. bash and fish have no equivalent ($BASH_ENV is for non-interactive bash only), so common/aliases.sh defines a bash and a fish wrapper off $_HI_SESSION_RC. Both bodies begin with command: fish’s alias builds a function of that name, and without it fish would call itself forever.

The wrappers cannot cover a bash or fish shell nothing typed — a tmux pane spawning a login shell, an editor’s shell-out — which comes up as the host’s own. The panes of a session hi keeps are the exception (HI.65). hi writes nothing into a target’s login files (COMPATIBILITY.md has the reasoning).

HI.47 what a child inherits

env | grep ^_HI_ in a process started from an interactive hi shell shows core.sh’s _HI_CHILD_ENV roster and nothing else with the prefix. The roster is seven names:

  • $_HI_HOME and $_HI_CONFIG_DIR — the overlay on a target is wherever hi.sh put it, and cannot be re-derived;
  • $_HI_REMOTE_SESSION;
  • $_HI_SESSION_RC — HI.46’s wrappers are re-defined in every nested shell;
  • _HI_TARGETS_TTL, _HI_PROBE_TIMEOUT, _HI_BACKENDS_OFF — the knobs sh targets.sh reads straight off its environment from a completion.

It works by taking the attribute off, not by never setting it. fish parses common/paths.sh alongside sh, zsh, and bash, and the one assignment all four accept is export NAME=value, so every name it sets arrives exported — over fifty. Each interactive rc (bash.sh, zsh.zsh, config.fish) un-exports the lot as the last thing in its required block: _hi_unexport in core.sh (one bash export -n, or one zsh typeset -g +x — a bare typeset inside a function declares a local — over every name), and a set -gu NAME $NAME loop in config.fish. The values stay as shell variables: the header’s clock reads $_HI_HUMAN_CENTRIC_DATE on every render, the prompt reads the colour memos, and a $( ) is a fork rather than an exec; an alias that names a path expanded it at definition time. Last in the block so the overlay’s per-shell rc, which runs after it, can export whatever it wants a child to see.

The flip alone is not enough because of the client’s verdicts — hi.sh’s _hi_session_env, pinned to core.sh’s _HI_SESSION_VARS. Two of them (_HI_LOCAL_USER, _HI_LOCAL_HOSTNAME) name the operator’s workstation, the one thing a target’s process table should never learn from hi; they are not in the roster, so a nested shell cannot inherit them. load.sh’s _hi_session_rc_setup writes them into each session rc instead (HI.46), as plain assignments between the target’s own rc and hi’s; fish, which shells out to bash for the header and the colours, hands them to that one bash -c through __hi_bash’s function-scoped exports (set -fx; a -l inside the loop would be block-scoped and gone before the command runs).

Not covered: a POSIX sh started inside a session reads $ENV, which sources paths.sh and exports the roster into that shell again — dash has no un-export. The bash-less fallback rc (HI.20) has the same shape for the same reason. Both are tiers below what load.sh styles. tests/common/exports_test.sh pins the contract: the child environment in all three shells, config.fish’s two mirrors against core.sh, _hi_session_env against _HI_SESSION_VARS, every env read in targets.sh against the roster, and the session rc’s quoting round-trip in each dialect.

HI.48 header cell hue resolution

$_HI_HEADER_ORDER (common/header.sh) lets any subset of seventeen words print in any order, each cell carrying its own hardcoded color. The shipped default is laid out so no two neighbors share a hue, but a user’s order can put any two of the sixteen colored words side by side; check, the seventeenth, resets the hue tracking instead (below).

_hi_collect_header_word fixes this in one pass, no lookahead or backtrack: it tracks the previous cell’s hue in $_HI_PREV_HUE, and when a word’s own color would repeat it, swaps in that word’s hand-picked alternate (_hi_header_word_alt). “Hue” ignores the bold bit (_hi_cell_hue): \e[0;36m and \e[1;36m both read as cyan and collide.

A single pass suffices because every word’s alternate has a different hue than that word’s own primary, so a substituted cell can never itself collide with what came before it. The shell does not enforce this; it is a property of the hand-written table, and tests/common/header_test.sh’s test_header_word_alt_differs_from_its_own_primary checks it mechanically.

A cell of the user’s own, from the overlay’s header/ (header.sh’s _hi_header_cells_load, admitted past the word gate by name), has no row of alternates: _hi_header_word_alt gives it the bright color of the next hue round the ring from its own (red to green, …, cyan to red), which keeps the property for it too.

An empty cell (containers/jobs/pods when that backend never answered) leaves $_HI_PREV_HUE untouched rather than resetting it to empty — resetting it would let the next word compare against nothing and skip a real collision two cells later. check resets it explicitly: the packages block has its own palette ($_HI_YES/$_HI_NO), unrelated to header cell hues.

HI.50 truecolor color schemes

_HI_COLOR_SCHEME (common/core.sh) remaps what the twenty-four palette names render as; it never adds a name. _hi_hash_color, the config/colors pins, _hi_color_escape, and hi --preview colors all keep the same vocabulary, so a scheme is invisible to everything that reasons about a color by name - only the bytes a name turns into change. The names are the terminal’s twelve plus twelve extras (orange, pink, teal, …) that no 16-color code spells: _HI_COLOR_FALLBACK gives every slot its <bold><hue> pair - an extra’s is the nearest of the sixteen - and with no scheme the first twelve render as that pair alone while the extras carry a built-in hex, so a truecolor terminal shows an orange host as orange without anyone choosing a scheme. _hi_color_base is the pair as a name, for zsh’s %F{} and fish’s set_color, which know the sixteen and nothing else.

A config/colors row may also carry its own hex, the second word of its string, and that is the one thing that outranks the scheme — for that pin only. _hi_colors_scan joins it to the name (brred#ff5f5f) and _hi_color_split takes the two apart again, so the pinned color travels as one string through the memos, $_HI_TARGET_COLOR over the wire and hi --preview colors’ grouping, and only the three readers above know it is two halves: the name is still the 16-color half of the escape (and all a terminal without truecolor is given), the hex replaces the scheme’s word in the 38;2 triple.

Those bytes are one SGR, \e[<bold>;3<n>;38;2;<r>;<g>;<b>m: the 16-color pair first, the 24-bit triple after it. A terminal that ignores 38;2 keeps the first; a capable one applies the last foreground it was given. One escape rather than two keeps every reader of a cell’s leading escape working unchanged - _hi_visible_width strips one prefix, _hi_collect_header_word cuts at the first m, scripts/table.sh and the suites’ _hi_strip_ansi match \e[[0-9;]*m - and _hi_cell_hue (HI.48) needed only to accept ; as well as m after the slot digit, which is still the hue.

The hex table is a fixed-width string sliced by offset (_hi_scheme_hex): no arrays, because zsh indexes them from 1 and sources this file; no separate data file, because the payload strips comments and the twenty-four six-digit words are the only bytes that cost anything on the wire. hi ships no named schemes — the only table in the tree is the default one, whose first twelve slots are 000000 (meaning “the terminal’s own”) and whose twelve extras carry a built-in hex. A scheme is always the user’s own. _hi_assign_palette builds $RED..$BRCYAN, shell variables a child never sees, through the same primitive as _hi_color_escape, so the two can never disagree and scripts/configure.sh’s previews can rebuild the palette under a pending answer.

The scheme is that same string, in the setting. _hi_scheme_words reads $_HI_COLOR_SCHEME by the same offsets and answers 24, 48, or 0: exactly that many six-digit hex words one space apart is a scheme, anything else — a leftover name, a typo, nothing — renders as the default. Forty-eight words are two banks of the twenty-four names: _hi_scheme_hex takes slot indexes 0-47 and folds 24-47 onto 0-23 for every table but a 48-word list, and _hi_color_escape_at reads the 16-color half at the index mod 24, so a second-bank escape wears its name’s \e[<bold>;3<n> and every hue and width reader above still works. Only common/header.sh’s packages check reads the second bank: _hi_packages_palette rebuilds _HI_YES/_HI_NO from it per render, after resolving the ramp, because the ramps are the palette variables and those are the first bank by contract. scripts/lib.sh’s _hi_scheme_ok and _hi_scheme_label are the validator and the preview/doctor label; core.sh only ever renders.

The packages check’s ramp is the same shape, one level down. $_HI_PACKAGES_PALETTE is eight _HI_COLOR_NAMES words — four for installed, four for missing — and _hi_ramp_ok (common/core.sh, beside the vocabulary it checks against, so scripts/doctor.sh reaches it without sourcing header.sh) is the one judge: _hi_packages_palette falls back to $_HI_PACKAGES_RAMP, the shipped ramp as one string, whenever it says no, and scripts/lib.sh’s _hi_ramp_label names the same three shapes the scheme label does. Like the scheme, there are no named ramps to pick from — hi --configure does not ask about either, and neither is in a preset’s vocabulary.

The gate is _hi_has_truecolor: COLORTERM (truecolor or 24bit), with _HI_TRUECOLOR overriding both ways. ssh never forwards COLORTERM, so hi.sh’s _hi_session_env ships the client’s verdict as _HI_TRUECOLOR beside _HI_ASCII - the escapes render in the client’s terminal, and the target must not guess from its own environment. The scheme name itself needs no transport: settings.sh is in the overlay. zsh takes %F{#rrggbb} from 5.7 (common/zsh.zsh, behind is-at-least) and fish takes set_color hex name, a list it resolves to the first entry it can render, so both fall back to the plain name on their own.

HI.51 docker-compatible CLI family

docker, podman, nerdctl, and finch take the same ps --format, exec -i[t], and container inspect -f grammar, so hi has one container arm and tries all four, in that order. Each member is its own kind: common/targets.sh builds its roster from the family and emits <name>\t<cli> per lane, hi.sh generates one _HI_BACKENDS row per member at load, and common/header.sh starts one probe lane per member on $PATH. A member that is absent costs a builtin command -v on TAB and one background subshell per hi <target> in _hi_resolve_backend; a present one is one parallel lane, capped like every other (HI.26). That is the whole cost of a member nobody has installed, which is why the family is the same four words everywhere and not a setting: there was nothing for a shorter list to buy.

The words are spelled twice: common/core.sh’s $_HI_CONTAINER_CLIS, read by hi.sh and common/header.sh, and common/targets.sh’s clis, since that standalone POSIX file cannot source core.sh. tests/lint/drift_test.sh’s lint_container_family pins the two together.

Two members can front one daemon — podman-docker ships a docker that execs podman, nerdctl and finch share a containerd — and would list every container twice. targets.sh’s dedupe_family keeps the first lane’s row in roster order (so a shim host sees docker), and the header unions the lane files, since the IDs are the daemon’s.

--use <backend> forces any arm by name, ssh and every roster row included, and is the only way to: there is no per-backend flag, so a member added to the family is reachable with no second spelling. Names stay plain identifiers ([A-Za-z0-9_]): hi.sh’s per-member predicate is eval-defined.

HI.52 client multiplexer wrap

hi --mux <target> re-executes the connect inside a local multiplexer session named hi-<target> and never returns; a second hi --mux to the same target joins the running session. It is the client-side answer to a dropped link; HI.65 is the target-side one. _hi_mux_tool picks the first of tmux, zellij, and screen on PATH, each driven in its own idiom:

  • tmux: new-session -A -s <name> <one string>; the -A is the reattach.
  • screen: -D -R -S <name> sh -c <one string>; -D -R reattaches a session of that name (detaching it elsewhere first) or creates it running the command.
  • zellij: takes a session’s command only from a layout file, never from argv, so _hi_mux_wrap writes hi.mux.<name>.kdl under hi’s runtime directory (one per target, rewritten each connect, each word a KDL string via _hi_kdl_quote) and starts it with --new-session-with-layout; a name already in list-sessions --short is attached instead.

Five rules in _hi_mux_wrap:

  • Where it sits. After _hi_parse, before _hi_select_arm, so one insertion point covers every arm (ssh, --plain, docker, nomad, kube). The inner argv is rebuilt from the parsed state (--use, --plain or --no-plain, --keep or --no-keep, the ssh options, $DOMAIN, the command), not replayed from "$@", so the target it settled on rides along.
  • The guard. The inner command is env _HI_MUX_INNER=1 <launcher> ...; the wrap returns at once when that is set. The inner argv carries no --mux/--no-mux of its own, so the inner hi re-reads $_HI_MUX - without the guard, _HI_MUX=1 would nest forever. _hi also skips the wrap without a terminal on stdin (nothing to attach), and it stands down without a multiplexer to use.
  • One string. tmux hands the command to its default-shell, which may be fish, and screen to sh -c, so the argv is joined into one string with _hi_shquote (HI.40): single quotes are the one form every shell reads the same way, where %q’s $'...' is bash’s alone. zellij gets the words.
  • The name. _hi_mux_name keeps [[:alnum:]_-] and turns everything else into -: tmux refuses : and . in a session name, zellij takes the same class, and / and @ read badly in a status line, so a kube ctx:ns:pod/ctr is hi-ctx-ns-pod-ctr.
  • Already inside one. tmux ($TMUX set) refuses to nest, so the session is created detached and the client switched to it. screen ($STY) has no client switch: a new window in the current session (screen -t <name>), and hi exits once it is made. zellij ($ZELLIJ) likewise gets a new tab from the same layout (zellij action new-tab --name <name> --layout).

HI.53 terminal reset after a failed session

_hi_reset_terminal (hi.sh) runs when a connect’s exit status is not 0 and both stdin and stdout are terminals. ssh restores the tty’s termios on its way out, but nothing restores the terminal emulator’s modes a remote program switched on and never got to switch off when the link went: application cursor keys (CSI ?1 l), the application keypad (ESC >), bracketed paste (CSI ?2004 l), a pushed kitty keyboard mode (CSI < u), the alternate screen (CSI ?1049 l, wrapped - below) and a hidden cursor (CSI ?25 h). It also closes the OSC 133 prompt-mark pair with a D carrying the status: hi’s remote prompt emits C before every command, exit included, and load.sh sends the closing D on a clean exit - a drop never reaches that line, and Konsole, left “inside a command”, sends ↑ as ← until a D arrives. stty sane last, for the container arms whose exec does not always restore termios on a lost link. Never on exit 0 (the session closed itself down), never on a pipe (hi host cmd | ... gets the command’s output and nothing else).

Every byte is a no-op on a terminal already in its normal state, so the caller need not know which mode applied - the alternate-screen exit only once wrapped in ESC 7/ESC 8 (DECSC/DECRC). Konsole answers CSI ?1049 l with an unconditional cursor restore, and on the normal screen that slot holds what nothing ever saved, i.e. home: the failed connect’s message landed at the top and painted over the session still on screen. Saving first makes the restore land where the cursor already is; a terminal really in the alternate screen saves to that screen’s slot, so CSI ?1049 l still restores the pre-alt cursor and the DECRC only repeats it.

HI.54 who draws the environment prefix

common/env_prompt.sh names every active environment manager as the prompt’s leading (mise|direnv:proj|myproj). The awkward part is not the detection - every tool exports a variable, so a draw is parameter expansion and nothing else - it is that two of those tools draw a prefix of their own, and whether that prefix survives is a property of the shell, not of the tool.

python -m venv’s activate script prepends to $PS1 (bash, zsh) or copies fish_prompt to _old_fish_prompt and wraps it (fish); conda prepends $CONDA_PROMPT_MODIFIER unless changeps1 is off. zsh and fish keep what those scripts did: zsh.zsh assigns $PS1 once at rc time, and fish’s fish_prompt is the very function activate wrapped. bash does not - common/bash.sh’s __hi_ps1() is a PROMPT_COMMAND hook that rebuilds $PS1 from $HI_PS1 on every draw, so the activate script’s edit is gone by the second prompt.

So $_HI_ENV_DEFER carries the shell’s verdict rather than the tool’s: zsh.zsh and config.fish set it to 1 and the venv and conda rows stand down when the tool’s own marker is present ($_OLD_VIRTUAL_PS1, the _old_fish_prompt function, a non-empty $CONDA_PROMPT_MODIFIER); bash.sh sets it to 0, because there is provably nothing there to defer to. A venv is therefore named in all three shells - in its own styling under zsh and fish, in hi’s under bash - and the tools with no prefix of their own are hi’s everywhere. While such a prefix stands, hi’s lead space drops out, since the prefix ends in a space of its own: zsh’s __hi_env_precmd sees $PS1 no longer starting with hi’s mark, and fish’s prompt_login the same markers. The alternative, exporting VIRTUAL_ENV_DISABLE_PROMPT=1 to silence the tools and always draw hi’s, would have hi overriding a setting the user configured for every other shell they open.

config.fish carries its own copy of the source list, as it does of the git glyphs: it cannot call the bash function, and a bash -c on every prompt draw is exactly the fork this prompt refuses everywhere else. tests/hi/prompt_test.sh pins the two lists together.

mise is the one row that is more than parameter expansion. $MISE_SHELL is set wherever mise is activated, and a ~/.tool-versions covers every directory under it, so (mise) is named only where a config file between the directory and ~ overrides the global one: a builtins-only walk up from $PWD, memoized on it (HI.16).

HI.55 re-entrant rc guard

An rc that leads back into itself recurses until the shell dies. Two shapes reach hi. On macOS, hi --install adds lines to ~/.bash_profile that source ~/.profile and ~/.bashrc, and a ~/.bashrc that sources ~/.bash_profile back - a common fix under tmux, whose panes are login shells - makes the pair ping-pong. And an overlay bashrc, zshrc, or config.fish that sources the user’s own rc re-enters hi’s, which sources the overlay again.

Both are cut by a plain, never-exported shell variable held only while loading: common/bash.sh, common/zsh.zsh, and common/config.fish return at once while _hi_rc_loading is set, and the .bash_profile lines skip while _hi_login is. Unexported, so a child shell - a new tmux pane - loads normally; cleared at the end, so a later source ~/.bashrc reloads. A load interrupted by ^C leaves it set in that one shell.

HI.56 listing-only completion symbols

bash’s completion has no description column - every COMPREPLY entry is a word readline may put on the command line - so a backend symbol beside a target name (web ▣) is safe only while readline lists matches, never when it inserts one. _hi_complete reads $COMP_TYPE: ?, !, and @ (63, 33, 64) list, so their entries carry the symbol; a plain TAB (9) inserts the common prefix and menu-complete (37) cycles whole entries, so both get bare names. A name two backends share is listed once with both symbols (dup »▣), or the entries’ common prefix would run past the name into the space. bash 3.2 has no $COMP_TYPE, so its list stays bare. fish and zsh carry the symbol in a column of their own (__hi_targets’ description, _hi’s -d display) and need none of this.

HI.57 carried configs and the include scan

hi carries a vim/vimrc, nvim/init.lua, nano/nanorc, emacs/init.el, helix’s helix/config.toml (and languages.toml), and kakoune’s kak/kakrc and its colors/ (through $KAKOUNE_CONFIG_DIR, since kak -n would drop its system kakrc too) to every target and starts the editor on it (-u, --rcfile, -nw -q -l, -c), so the question is which file - HI.61’s order answers it: the overlay’s copy, then the config that editor already reads on this machine (~/.vimrc, $XDG_CONFIG_HOME/nvim/init.lua, ~/.nanorc, ~/.emacs, …, in the editor’s own precedence). hi ships no editor config of its own, so with neither the value is empty and the command has no alias; tmux/tmux.conf (tmux -f) and screenrc (screen -c) take the same tiers, and micro and zellij take a directory. The alias is a target’s alone, a line of the overlay’s wiring.sh (HI.62): at home every tool reads its own config unasked, an overlay copy is what targets get, and vim -u would drop the system vimrc and defaults.vim. The middle tier is HI.32’s argument applied to editors - one copy to edit, no duplicate in the overlay to keep in step.

Carrying a real config makes a second problem real with it. Every overlay member - these rcs, the shell overlay files, the prompt configs - ships into the target’s config/, so a line naming a path - a second rc beside it, a plugin directory, a manager’s bootstrap - names something no target has, and the editor or shell fails on it rather than hi. scripts/pack.sh’s _hi_lint_awk finds exactly those lines; the per-dialect grammar, and what it deliberately leaves alone, is the comment above it. One pass serves both readers: _hi_stage_tar runs it in fix mode ahead of HI.35’s stripper, so a finding goes out disabled in its own dialect and the strip drops it for free, and hi --doctor runs it in report mode, so its yellow rows name exactly what went missing. The grammar is a row of scripts/pack.sh’s $_HI_DIALECTS - its comment leader, where a statement ends, what is an include, a plugin manager, or allowed, and how a finding is disabled - named by the member’s row’s <dialect>, so doctor reads ~/.vimrc as vim, a row of the user’s is read in the dialect it names, and a member with none (an eza/theme.yml) passes through untouched. A line directly under a hi-allow comment in the file’s own syntax is neither reported nor touched; one under hi-quiet is disabled like any other finding but not reported. A pair, hi-allow-start and hi-allow-end or hi-quiet-start and hi-quiet-end, decides every line inside it the same way; each word pairs on its own, a start with the next end of its word, so an allow pair inside a quiet one keeps its lines. A start with no such end decides nothing and is a row of its own kind, unclosed, which hi --doctor names; an end with no start is ignored. A line under hi-carry is the one marked line that is never a finding: each file under $HOME it names rides as written - no scan, no strip, since it is no config of the member’s dialect - under <tool>/carried/, or <member>.carried/ where the member has no directory, and the line holds the carried path as a carried include does. What of it cannot ride is a row of the kind carry. The scan cannot know a start is closed until the file ends, so it reads the file through once for the pairs before it reads it for findings. There is no setting that turns the scan off: the comments are the per-line and per-block answer.

Disabling must leave a file that parses. vim and nano are line-oriented, so a finding is one line (tmux’s takes its \ continuations). lua, elisp, and zellij’s kdl are not, so the comment runs to the end of the bracket-balanced expression the finding opened - commenting only the matched line of require("lazy").setup({ would leave its }) behind. bal() counts that depth blind to strings, and takes ' as a string delimiter for lua only: in elisp it is the quote operator, and reading 'load-path as an opening quote swallows the rest of the file. sh and fish are the reverse problem: commenting out . ~/x inside if ...; then leaves an empty body. So only the verb and its one file word become : (true in fish), and the guard, the &&, the case arm around it stay. What the pass cannot see is a value the dropped line was meant to bind - a local m = require("x") used twenty lines down - so a plugin-heavy config can still error on the target; the doctor rows make that legible.

An include naming a file of the tool’s own directory is carried instead (_hi_stage_carry). For a member <tool>/<file>, that is its source’s directory (not $HOME itself), and at home $XDG_CONFIG_HOME/<tool>, ~/.<tool>, and ~/.<tool>.d; a path spelled from ~, $HOME, or $XDG_CONFIG_HOME is read as it is here. The file rides as <tool>/<its path under that directory>, scanned in the member’s dialect, so its own includes carry too, and the include’s path becomes @@HI_CONFIG@@/<tool>/..., a word the target makes its overlay directory as the overlay lands (_hi_overlay_fixup, one grep -rl and a sed per file that has it). The line is not a finding, so hi --doctor does not name it. A relay sends the carried copy with the rest of its tree, and its own overlay directory as the word (HI.66). The overlay cache watches the carried files through the list the last build left beside it. An include naming a module rather than a path - lua’s require("x") - is not one this reads, and is dropped as before.

One finding is given back. A nanorc whose syntax include (*.nanorc outside /usr/share/nano) was dropped would highlight nothing, so the stripper keeps that finding’s comment, and load.sh’s _hi_nano_fallback puts an include right under it: the dropped one again where its absolute path matches files on the target, else include "/usr/share/nano/*.nanorc" where the stock set is there. Placement matters because nano resolves an extendsyntax as it reads it; so does the name, since nano-syntax-highlighting spells GO what the stock set spells go. Each extendsyntax takes the target’s spelling, compared without case, and one naming no syntax there becomes a # hi dropped: comment the stripper keeps too. The file is rewritten from those comments each session, not patched once, since the copy rides on to a next hop’s target, which may have a different set.

HI.58 overlay directory members

A $_HI_OVERLAY_FILES entry ending in / names a directory, and its members ride one by one: scripts/pack.sh’s _hi_overlay_files lists each as <dir>/<name>, in name order, over the overlay’s directory and home’s, each name once and the overlay’s copy first (zellij’s layouts/ and themes/, kakoune’s colors/), or over the overlay’s alone where the row has no home (extensions/, header/), and the rest of the stream - _hi_overlay_src, the cache key, the stager, HI.35’s strip (by the directory row’s dialect) - treats that path like any member. The directory stays an allow list: core.sh’s _hi_dir_member_ok admits a plain name only (a letter or digit first, then [A-Za-z0-9_.-], not ending .bak/.orig/.rej/.tmp), and the target reads the directory back through the same function, so a file hi would not send is one hi would not read either. The archive carries no directory entry; every tar hi unpacks with creates the parent, busybox’s included.

The two directories of code, extensions/ and header/, are listed by one function, core.sh’s _hi_dir_members: the members in name order, and every other entry apart, for hi --doctor to name. Each loader parses a member before it sources it, and skips one that does not parse with a line on stderr.

HI.59 extensions

extensions/ is a directory of HI.58 with no home but the overlay: each member is an extension, a file in the POSIX+fish subset common/aliases.sh keeps (export, alias, && chains), so one file serves all three shells and something new - another tool’s init, a prompt segment - rides to every target with no edit to the tree. common/paths.sh exports $_HI_EXTENSIONS unguarded; the overlay is its only home. A plugin is the other thing: a config hi carries for a tool (HI.64).

The moment is stated: right after $_HI_ALIASES (so an extension sees, and can replace, hi’s aliases and the overlay’s) and before the prompt is built, in name order - core.sh’s _hi_load_extensions for bash and zsh, and its fish copy in common/config.fish, which cannot call bash. Each member is parsed first by the shell loading it (bash -n, zsh -n, fish --no-config -n); one that does not parse is skipped with a yellow hi: extension <name> does not parse in <shell>; skipped on stderr rather than half-run, which is also what a fish-only or sh-only construct costs in the other shell. The parse is a fork per extension per shell start, and nothing without one. The glob sits in its own function (_hi_dir_members, the listing HI.58’s header/ shares) so zsh’s null_glob can be local there: local_options in the loader would also undo every setopt an extension makes. That function tests -d on the directory before it globs: unset, the pattern is /* and the loader would source what parses at the root of the disk (HI.60 is how it comes to be unset). fish’s copy needs no such test - an empty variable takes the whole word with it there.

Hooks are variables, since the subset cannot define a function all three shells read. The loader unsets each before an extension runs and collects it after, so extensions compose without ${var:+...}, which fish lacks. The set:

hook what hi does with it
_HI_SEGMENT a command and its words, split at spaces and run as they stand (no shell syntax, no glob, no eval) on every prompt hi draws; non-empty output is drawn after the environment prefix, followed by a space. bash marks any color in it for readline, zsh doubles its %. Ignored under a prompt tool.
_HI_PROMPT_INIT a prompt program’s init in HI.67’s shape (oh-my-posh init {shell}, {shell} the shell’s name), run in place of hi’s prompt where its command is on the target.
_HI_PROMPT_DRAWN 1 says the extension drew the prompt itself; hi’s prompt stands down, as it does for a program a target’s rc started.

hi --doctor lists the extensions in load order and warns for each a shell on this machine cannot parse, and for a directory entry that is not a member (doctor_code_dir, which reads header/ the same way, by bash alone).

HI.60 a shell that outlives the tree

An interactive shell is long-lived and the tree under it is not: hi --update and a package upgrade both rewrite say-hi/ in place, and every shell already running keeps what it loaded. A tmux pane is the extreme case - panes last weeks, and the usual reflex after an upgrade is

tmux list-panes -a -F '#{session_name}:#{window_index}.#{pane_index}' |
  xargs -I {} tmux send-keys -t {} 'source ~/.bashrc' Enter

which re-sources the new tree’s rc in all of them at once.

common/core.sh’s preamble is guarded by $_hi_core_loaded so a second source in one process is a no-op, and scripts rely on that: configure.sh stages values the re-run of common/paths.sh would write back over. The guard is wrong in exactly one place, the rc, where the second source is not a second source at all but a different version’s. Functions below the preamble are redefined either way, so the guard left the new tree’s code running against the old tree’s paths, and every _HI_* path added between the two versions stayed empty. The damage is quiet and cumulative: source "" for a name that did not exist yet (bash: : No such file or directory), _hi_env_prompt: command not found on every prompt draw, the header’s package row gone, and "$_HI_EXTENSIONS"/* globbing / - which HI.59’s loader then bash -ns and sources, file by file, in every pane at once.

So common/bash.sh and common/zsh.zsh unset _hi_core_loaded before they source core.sh. load.sh already did, for the same reason on the far side (a target whose own ~/.bashrc wires a say-hi of its own loads that tree’s core.sh first), and common/config.fish never had a guard to clear. _hi_dir_members tests -d on the directory it is handed before it globs, so no later name arriving empty can reach the root of the disk again.

HI.61 one overlay priority

Every overlay member resolves in one order, written once as scripts/pack.sh’s $_HI_OVERLAY_TABLE and the rows config/plugins adds to it (HI.63): the overlay’s copy, else the user’s own file at home, else the tree’s default where config/ holds one. A row names the member, the common/paths.sh variable that carries one of hi’s own files to the shells (- for a tool’s member), whether config/ holds a default ($_HI_OVERLAY_SHADOWS is derived from that column), the tool that reads it (the binaries _hi_tool_here looks for, - where nothing is looked for), its group and its plugin, which is its name in hi --doctor, its wire and the toggles that keep one of hi’s own files home, and the home tier: candidate paths best first, a @function where no list can say it (oh-my-posh’s rc-named config, the theme a framework’s rc variable names, ssh’s tag map), or - for none. The table holds hi’s own files alone, which the list never switches; every row it does switch is a plugins file’s. _hi_overlay_src - the stager, the cache, the include scan - and hi --doctor read the table, and a tool’s member is resolved nowhere else: _hi_overlay_home walks its row’s candidates as the overlay is packed. paths.sh cannot read a table, its four-shell dialect having no loop, so it spells out the rows of hi’s own files (settings.sh, colors, packages, extensions/), a line per candidate, and paths_test.sh walks those rows down their tiers against it. No setting reorders it. hi --doctor’s files section (doctor_files) walks the same rows, naming each member’s tool and marking every location that holds something used or passed over, and why nothing is sent when something is there.

The home tier is the config in force here: client-only, since a relay packs nothing of the middle box’s (HI.66), and only with the member’s tool on this machine (_hi_tool_here). Whichever tier answers, the include scan (HI.57) runs over it on the way out.

A shell’s own rc has no home tier. bashrc, zshrc, and config.fish are where people export tokens, and a ~/.bashrc found at home would run on every box visited without the user having asked, so they ride only from the overlay, where a copy is the user’s say-so (SUPPORT.md keeps the same line on carrying ~/.bashrc). extensions/ and settings.sh live in the overlay alone anyway.

A member is <tool>/<file> where its tool keeps a directory under ~/.config, the file named as the tool names it, so the overlay has the shape of the ~/.config it stands in for and no two tools ask for one name; a tool that keeps none (starship.toml, inputrc, screenrc) has its member at the top. A home candidate is a file, or with a trailing / a directory the member’s file is looked for in: micro and zellij take a directory of fixed names, so each of their files resolves on its own against the tool’s directory ($MICRO_CONFIG_HOME, $ZELLIJ_CONFIG_DIR, else the XDG one), and zellij’s layouts/ and themes/ are trailing-/ entries (HI.58).

HI.62 generated wiring

Pointing a tool at its carried config is a line on the target, and the target’s line has to parse in bash, zsh, fish, and sh: common/paths.sh’s dialect, which has no loop to walk a table with. So the client writes the lines. A row’s wire column says how: env:<variables> exports each as the member’s path, envdir:<variable> as the directory holding it (eza and its fixed eza/theme.yml), flag:<command> <words> and flagdir: alias the command to itself with the words and that path, where the target has the command (a flag ending in = takes the path in the same word), xdg:<command> aliases it with $XDG_CONFIG_HOME set to the overlay, where the <tool>/<file> members sit as they would under ~/.config, and - leaves the member to hi’s own code. xdg: is the fallback: everything the command starts inherits the variable, so it is for a file no variable or flag reaches (helix’s languages.toml), and its row follows the flag row’s so its alias replaces that one when both files ride. A row holds several wires with a ; between them, read in order, a later alias replacing an earlier one. An env: over a name of hi’s own is how hi’s code on a target learns a member’s path without naming the member: $_HI_VIMRC and $_HI_NVIMRC for load.sh’s $VIMINIT, and $_HI_NANORC for its syntax fallback. <names>=<command> aliases other names to the command, and words ahead of it that hold a = are its environment: neovim answers to vim as well and keeps its state under the session tree. helix’s rows wire hx and helix each as itself and alias neither name to the other: which name a target’s helix answers to is the user’s call, in their own aliases.sh. The path goes in bare: load.sh reads an alias’s body back for $EDITOR, and a quote inside one does not survive that.

micro’s row carries two flags ahead of -config-dir, -backup false and -savehistory false: its config directory is the session’s tree, and a backup or a history written there is lost with it. _hi_overlay_wiring turns the members an overlay archive carries into wiring.sh, which _hi_overlay_tar stages beside them, and paths.sh sources it on a target only. At home each tool’s own config is already in force.

A target therefore knows no wired member by name, tests no file per member per shell start, and gets no line for a member that stayed home. The paths are written under $_HI_CONFIG_DIR, unexpanded, so the file holds on a hop taken from inside a session, which sends it as it stands. wiring.sh is no member: one in the overlay is not read, and the archive has none when no member it carries has a wire.

The lines are part of _hi_overlay_cache_key. The member list alone would hand an archive cached by an older packer to a newer one that wires the same members another way.

No line is written behind a test of a setting: what is switched off is decided on the client (HI.64), and has no line. An alias line ends || true, since the target may lack the command: the file’s status is its last line’s, and core.sh sources paths.sh under set -e.

HI.63 plugins rows

The tools’ rows are written as plugins, in a file: the tree’s config/plugins, and the user’s in another of the same shape, the overlay’s plugins. Each is TOML in the subset core.sh’s _hi_toml_row reads, a key = "value" to a line, # lines and blank ones skipped. A [<group>.<name>] table is a plugin: files is its members, a space apart, tool the commands that read them (left out, the name; - for none), and wire, home, and dialect what each file has unless a [<group>.<name>."<member>"] table under the plugin gives that file its own. scripts/pack.sh’s _hi_plugins_load reads both files into $_HI_PLUGIN_ROWS, a row a file in the table’s own shape (HI.61), so the order, the tool check, the include scan, the cache, the wiring (HI.62), and hi --doctor take a row of the user’s as they take one of hi’s. The overlay’s file is read after the tree’s, and a plugin of a name the tree’s has replaces the tree’s whole, where it stood. Both are read once per tree and $_HI_CONFIG_DIR, the first time a row is asked for.

The tool is the record because the tool is what is switched, wired, and named in a report: a key written once serves every file of it, and a new key is a line a 1.x can add, where a column was a break.

A home column, the table’s or a file’s, is data and is never evaluated. _hi_path_list reads it as candidates a : apart, best first, each a path starting at /, at ~/, or at $NAME, which is that variable’s value and drops the path while it is unset or empty. A candidate may be several paths a , apart, of which the first not dropped is the one: $NAME/rc , ~/.rc is ${NAME:-$HOME}’s place, where a tool looks once its variable is unset. Nothing else expands: no ${NAME:-default}, no command substitution, no glob. A grammar can grow in a 1.x where an eval could never be narrowed. A home of @<function> is a lookup of the packer’s own; the tree’s file may name one and the overlay’s may not.

What the table cannot hold is left out and kept, with its file, its line and the reason, in $_HI_PLUGIN_BAD for hi --doctor: a key above the first table or of no name hi reads, a line that is neither, a table that is no [<group>.<name>] (the rows this shape replaced among them, which hi --configure converts), a second plugin of a name in one file, a file’s table under no plugin or for a file it has not, a plugin of no files; a member that is no <name>, <dir>/<name> or <dir>/ (_hi_plugin_member_ok), that is hi’s own, another plugin’s, or the directory one sits under, or that is wiring.sh or plugins; a tool that is not command names or -; a wire that is not env: or envdir: over variable names, flag: or flagdir: over a command and its words, or xdg: over one command, wires a ; apart; a dialect $_HI_DIALECTS has no row of. A file that is turned down takes nothing of its plugin with it. The wire is checked because its words become a line every target sources: its names, its environment, its command, and its words each hold nothing that runs or expands there beyond a $NAME. The check and _hi_overlay_wiring take a wire apart through one function, _hi_wire_read, so what is admitted is what is written.

Neither file rides. The packer is their one reader and stays home (HI.66), so the tree’s is cut from every payload ($_HI_PAYLOAD_CUT) and the overlay’s is no member, plugins being a name no row may take. A hop taken from a target sends the session’s config/ as it stands, so the members that rode and their wiring arrive again, and nothing of that machine’s home does.

HI.64 what is switched off

A plugin is a [<group>.<name>] table of a plugins file (HI.63), its name the row’s plugin column and its group the row’s group column (editors, mux, prompt, cli, shell, or one the user names). A row with no group is hi’s own file, which the list never switches; only its off column can (packages, under _HI_DISABLE_HEADER). _hi_plugin_off answers for a member: off when $_HI_PLUGINS_OFF names its plugin, its group, or the member itself (its row’s, or its own where it is one file of a directory row, extensions/10-kube), or when a toggle of its off column is 1.

It is asked in _hi_overlay_src and _hi_overlay_files, the two gates every member passes on its way out, so what is off is not packed, has no line in wiring.sh (HI.62), and is not in the cache key’s member list. The target is handed the result and needs neither the list nor the table. An overlay copy stays home too: off is the user saying no, which outranks a file saying yes.

Two things keep it cheap and right. Every member asks, several times a connect, and nearly always nothing is off, so that verdict is kept for the values in force: the list, and each toggle an off column names ($_HI_OFF_TOGGLES). And a toggle is not always what the environment says: under _HI_DISABLE_LOCAL=1 common/paths.sh has set every one for this machine alone, so there _hi_toggle_on reads settings.sh’s own export NAME=value lines, the last of a name winning, as text.

scripts/plugins.sh and hi --configure both write the list, as one _HI_PLUGINS_OFF line of settings.sh in the wizard’s padded, marked spelling, so each rewrites the line the other left. load.sh reads one word of it on a target, from the settings.sh that rode: with editors off it exports no $EDITOR.

HI.65 kept session

hi --keep <target> (or _HI_KEEP=1) runs the session inside the target’s tmux, zellij, or screen, so it outlives the connection; HI.52 is the same idea on the client. All of it is the ssh arm’s and the bash tier’s: a container arm, --plain, and a bash-less target connect as usual.

  • The name. hi-<target>, from _hi_mux_name as --mux names its local session: the target as typed on this client, so two clients that call a host the same thing reach one session.
  • Reattach comes first. Every interactive connect, --keep or not, carries _hi_keep_attach between the preamble and the unpack. Its _hi_kept asks tmux (has-session -t =<name>), then zellij (ls -n, exited sessions dropped: attaching one would resurrect it), then screen (-ls, Dead entries dropped), and a hit is attached and the script exits there, with nothing unpacked. --no-keep and hi <target> <cmd> leave the block out; without a terminal it does nothing.
  • The owner pane. With no session to attach, _hi_keep_start replaces the bash handoff with tmux new-session -s <name> (or zellij’s or screen’s start) running the same bash --rcfile hi.bashrc -i, under the config hi carried, in the first of the three the target has. load.sh runs in that pane as in any session, so its exit hook is still the one thing that removes the tree: on exit, a killed session, or the timeout.
  • The environment is an argv. A multiplexer server already running hands a new pane its own environment, not the caller’s, so the session’s variables ride as env NAME=value ... ahead of bash. The subshell that execs the multiplexer unsets them first: a server started here would otherwise carry the client’s verdicts to every other pane (HI.47). $_HI_KEEP_MUX and $_HI_KEEP_NAME are how load.sh knows it is the owner.
  • zellij starts from a layout. It takes a first pane’s command from a layout and nowhere else, so the start writes hi.keep.kdl beside the rc: zellij’s own tab and status bars around one pane whose args are that env argv, each word a KDL string with its " and \ escaped. Its options keep the session off the disk (--session-serialization false: nothing under ~/.cache/zellij to resurrect), make a dropped client a detach whatever the config says (--on-force-close detach), and turn off the startup-tip and release-notes popups, which would take the first prompt’s keys - those two only where zellij options --help lists them, since a zellij handed an option it does not know refuses to start.
  • The trap stands down. On a connect that keeps, the bootstrap’s trap 'rm -rf $_HI_CLEANUP' exit is guarded by _hi_kept ||: bash as sh runs an exit trap on a hangup, and a dropped link would otherwise take the tree from under the session.
  • Kept from inside. hi --keep with no target, typed in a session, keeps that session: _hi_keep_here runs the same attach and start under sh, from where a connect starts the pane. Three things make that possible. The attach block exports $_HI_KEEP_AS, the target as the client typed it, so the name is the one a later hi <target> looks for. load() writes hi.keep in the tree, a NAME=value a line: that name, the client’s verdicts, the tree, and its own pid as $_HI_KEEP_OUTER; hi.sh, a child that inherits none of them (HI.47), reads the pane’s argv off it, and drops its own exports first so the multiplexer it starts inherits a child’s environment and no more. And the tree now has two sessions on it, so the last one out removes it: the start leaves hi.kept as the kept session’s claim, which stops the first session’s clean_all and its bootstrap trap ([ -e hi.kept ] ||, on every connect that could be kept); the owner pane’s clean_all gives the claim up and leaves the tree while $_HI_KEEP_OUTER is still running. A session with no hi.keep - a container’s, a --no-keep one, an owner pane - says it cannot be kept, as does one already inside a multiplexer.
  • The other panes. A multiplexer opens a new pane on its default shell, the host’s own, which reads none of hi’s rc (HI.46). The owner pane’s _hi_keep_panes leaves a launcher, hi.pane, beside the rc - what load() exported for the session shell, then that shell’s own command - and makes it the session’s: tmux’s default-command and screen’s shell, set for this session alone, and zellij’s --default-shell, which it takes at the start only, so hi.sh names the path there. Those panes run on the owner’s tree, so the owner’s end is the session’s: clean_all kills the session after it removes the tree.
  • Closing. load() loops: when the pane’s shell exits with a client attached, _hi_keep_stays asks, and anything but y detaches the client and starts a fresh shell. zellij has no command for that - its action detach leaves an attached client where it is - so there the fresh shell comes with a line naming the key. With nobody attached it closes. hi --end <target> kills the session over one ssh call, and the pane’s bash takes the hangup.
  • A session that died. A target that goes down kills the owner pane with no exit hook run, and a /tmp that outlasts the reboot keeps its tree. So every owner pane’s load() writes hi.kept (_hi_keep_claim): its pid, then $_HI_KEEP_OUTER’s where it was kept from inside. A connect that looks for a kept session and attaches none runs _hi_keep_sweep once it has a tree of its own: each sibling of that tree (<user>.hi.*, the same mktemp template in the same directory) whose claim names no process still running is removed. Liveness is asked of the pids and not of the multiplexer, which a connect with another socket directory cannot see; a pid some other process of the account’s now holds leaves the tree for a later connect. An empty claim - hi --keep typed inside, its pane not up yet - and a tree with none are left alone.
  • The client’s record. A client cannot see a target’s sessions without connecting, so it notes the ones it has seen: an empty hi.kept.<key> in hi’s runtime directory, <key> the hash of the target and the ssh options that names the connection’s control socket. The target’s script is what writes it. A connect that looks for a kept session runs it as sh -c '...; e=$?; rm -rf <scratch>; exit $e' rather than the bare pair of commands, so its status outlasts the scratch directory’s removal, and the script ends 86 with a kept session left behind - off the attach, or at its end, which covers a hi --keep typed inside - and 0 otherwise, never the session shell’s own status. _hi_keep_connect turns 86 into 0 and the record on, 0 into the record off, and leaves it alone on anything else (255, a link that dropped). A connect that keeps writes it before it connects, since a drop says nothing; hi --end removes it. With the record there, the next connect’s script carries one more line after the attach: no session by that name, and it says the kept session is gone before it goes on. The runtime directory does not outlive a logout, and a client that forgot expects nothing.
  • The retry. In a pane of a local multiplexer ($TMUX, $ZELLIJ, or $STY, and a terminal) nobody may be watching when a link drops. There, a session that was up, ends 255, and has the record is retried: every five seconds for $_HI_KEEP_RETRY (5m; 0 is never) from the drop, then one line saying the target did not come back. A try is _say_hi again with ConnectTimeout=10; the boot call’s stderr goes to a file until the target answers, and a boot call ssh itself failed ends the try there, with no PowerShell fallback for a host that was not reached. A try that gets in and drops within ten seconds does not restart the window. A connect that never got in is not retried.
  • The timeout. _hi_keep_watch is a background job of the owner pane, polling once a minute: $_HI_KEEP_TIMEOUT (24h, read from the settings.sh that rode) with no client attached, and it kills the session. clean_all kills the job by process group, so its sleep does not outlive a session closed another way. zellij’s clients are the rows of action list-clients under its header; an answer with no header - a zellij too old to list them, or one in its first second - counts as attached, so neither the timeout nor an early exit closes a session somebody is in.

HI.66 the packer stays home

What a connect sends is built by scripts/pack.sh, which hi.sh sources where it finds it: the overlay table and the plugins rows, home’s lookups, the include scan, the comment strip, the staged tars and their caches. Only the machine that owns the config runs any of it, and scripts/ never rides, so none of it costs a byte on the wire.

A session’s tree has no scripts/, and needs no packer: the tree is the payload, already stripped, with the overlay unpacked over config/. There hi.sh defines the packer’s side of a connect itself, in five short functions: no overlay members, no cut list, no cache, and a _hi_payload_tar that tars $_HI_PAYLOAD as it stands. A relay sends what it was sent.

  • The seam. _say_hi and _say_hi_container reach the packer through _hi_overlay_files, _hi_payload_excl, _hi_payload_cached, _hi_payload_tar, and _hi_payload_stream, the five a session defines, and through four that run only with an overlay to send or outside a session (_hi_overlay_cached, _hi_overlay_stream, _hi_overlay_bytes, _hi_prompt_here). payload_test.sh holds hi.sh to that list.
  • Carried includes. An include the client carried names the config directory of the hop it landed on, once _hi_overlay_fixup has run there. A relay hands that path on as the token, so the next hop’s fixup makes it its own; a path a script cannot hold bare is no token, and nothing is rewritten.
  • Not a broken install. Outside a session a tree without the packer is incomplete, and _hi refuses before anything is sent.

HI.67 shell hooks

A plugin’s init is how its tool installs itself into a shell: a command printing that shell’s code, {shell} in it the shell’s name, which every such tool documents as eval "$(<tool> init bash)" and <tool> init fish | source. hi runs it in one place, core.sh’s _hi_run_init (and its fish copy), where the command’s first word is on the target, after the aliases and extensions and before the prompt - the one site in the tree that evaluates a tool’s own text, which tests/lint/eval_roster holds as its tool row.

The client decides what rides, as for every member: _hi_overlay_wiring writes _HI_HOOKS (<group>.<name>=<init> rows a ; apart) for every init plugin whose tool is here and whose plugin the lists leave on, and for a prompt plugin (prompt = "yes") _HI_PROMPT_INITS and _HI_PROMPT_PLUGINS instead, so the prompt hand-over (HI.32) starts it and nothing else does. A target runs a hook only where it has the tool, and only when the settings it was sent leave it on (_hi_hook_on): a leading - on the name says the plugin is off by default, and then _HI_PLUGINS_ON has to name it or its group. The shipped hooks group is off that way, since each of its tools keeps state under a target’s $HOME. _hi_prompt_row reads _HI_PROMPT_PLUGINS beside _HI_PROMPT_TABLE, so a prompt program the table never heard of is picked the same way; its configs ride as its plugin’s files, with no table column to name them.

The decision stays data: an init is a command and its words, refused with a quote, ;, |, &, $, a backtick or a bracket in it, since what runs is what the command prints and not the line itself.


This site uses Just the Docs, a documentation theme for Jekyll.