Loadouts

A loadout is a per-developer bundle of packages, environment variables, file patches, and lifecycle hooks that the min session CLI layers into the sessions it activates. The project’s minimal.toml describes what every contributor’s session needs; a loadout carries what you want on top (your editor, terminal multiplexer, shell config, and dotfiles) so each development environment comes up matching your muscle memory.

Loadouts apply to sessions (min session activate); they are not used by task sandboxes, which have their own packages/env_vars/patches schema described in Tasks.

Where loadouts live

Each loadout is a single TOML file at:

<config>/minimal/loadouts/<name>.toml

<config> is the platform user config directory: $XDG_CONFIG_HOME on Linux (or $HOME/.config when unset); macOS also uses $HOME/.config for consistency with Minimal’s state and cache dirs. The global --config-dir flag overrides the base, and min dirs prints the resolved loadouts directory.

The filename stem is the loadout’s identifier:

  • Nothing inside the file names it, so renaming the file renames the loadout.
  • Names are trimmed and must be non-empty, with no /, \, or NUL characters.
  • A file that still carries the old name field loads anyway, with a warning that the field is no longer required. If the declared name disagrees with the filename, the warning says so and the filename wins; the declared name is discarded.

The directory is not created automatically; create it and drop <name>.toml files there to get started.

Example

A loadout that brings in the helix editor and zellij multiplexer, wired up with the user’s dotfiles:

description = "helix + zellij with my dotfiles"
packages    = ["helix", "zellij"]

patches = [
    # Helix: single config files plus a themes directory.
    { dest = ".config/helix/config.toml", source = "~/dotfiles/helix/config.toml" },
    { dest = ".config/helix/languages.toml", source = "~/dotfiles/helix/languages.toml" },
    { dest = ".config/helix/themes/", source = "~/dotfiles/helix/themes/**/*.toml" },

    # Zellij: single config file plus a layouts directory.
    { dest = ".config/zellij/config.kdl", source = "~/dotfiles/zellij/config.kdl" },
    { dest = ".config/zellij/layouts/", source = "~/dotfiles/zellij/layouts/**/*.kdl" },
]

[vars]
EDITOR    = "hx"
VISUAL    = "hx"

# Declared to warm helix's tree-sitter grammar cache when the session
# comes up. Best-effort; failures don't tank activation.
[[lifecycle_hooks]]
on_activate = { type = "inline", value = "hx --grammar fetch >/dev/null 2>&1 || true" }

Saved as <config>/minimal/loadouts/dev.toml, this is applied with min session activate --loadout dev, or automatically via default_loadouts.

Loadout schema

name - The loadout’s identifier

Not a field. Comes from the filename

A loadout is identified by its filename stem — dev.toml is the loadout dev — which is what selection and error messages show.

The field itself is obsolete. A file that still declares one loads with a warning that it is no longer required, and the filename is used regardless:

# Delete this line; the file is already named `dev.toml`.
name = "dev"

description - Describe the loadout

Optional

Free-form text, shown alongside the name in min loadout list.

description = "Editor + terminal multiplexer"

packages - Packages to bring into the session

Optional

Package names installed into the session, in addition to the session’s baseline packages and anything the project contributes. Duplicates across contributors are deduplicated.

packages = ["helix", "zellij"]

Names are not checked at activation: an unknown package composes cleanly and fails later, when the session first spawns, with no such package: <name>.

[vars] - Environment variables

Optional

Variables set in the session environment. Names must be POSIX-shaped ([A-Z_][A-Z0-9_]*); for other names, see [[vars_lenient]]. Each value takes one of three forms:

[vars]
EDITOR = "hx"                                # literal value
PAGER   = { inherit = true, default = "less" }  # inherit, with fallback
MUXER = { inherit = true }               # inherit from the host env
  • A literal string sets the variable to that value.
  • { inherit = true } passes the variable through from the environment of the min process on the host. If the host does not have it set, the variable is dropped from the session (with a warning) rather than failing activation, so opportunistically inheriting things like COLORTERM is safe. TERM is not worth inheriting: the daemon sets it from the terminal you attach from and keeps it current, above anything a loadout composes — see The attached terminal.
  • { inherit = true, default = "..." } inherits, falling back to default when the host does not have the variable set.

inherit = false is rejected; omit the variable instead.

The warn-and-drop rule is a loadout relaxation, not a property of the spelling. The same { inherit = true } in a project’s [session.vars] is required: an unset host variable there fails activation instead of being dropped. A project is declaring what every contributor needs, where a loadout is declaring what one developer would like. See Where { inherit = true } differs for the rule on every surface that accepts the form.

[[vars_lenient]] - Environment variables with non-POSIX names

Optional

An explicit opt-in for the rare variable whose name is not POSIX-shaped. Anything the Linux kernel accepts is allowed (no =, no NUL). Values use the same three forms as [vars].

[[vars_lenient]]
name  = "weird-thing"
value = "x"

patches - Files copied from the host into the session

Optional

Each row names a source on the host and a dest inside the session, with an optional description.

patches = [
    { dest = ".psqlrc", source = "~/dotfiles/psqlrc" },
    { dest = "certs/",    source = "~/ca/*.pem" },
    { dest = ".config/nvim/", source = "~/dotfiles/nvim/**/*.lua" },
]

source is a host path or glob pattern, or a list of them (a list fans out into one independent patch per entry, each sharing the dest — so list entries only make sense with per-entry dests or glob entries):

  • A leading ~ expands to the host home directory. $NAME / ${NAME} references (including $HOME) resolve against the session’s already-resolved variables (declared in [vars]); referencing an undefined name is an error. $$ is a literal $.
  • $LOADOUT_ROOT expands to the loadout’s own directory, for files you ship beside the loadout itself — see $LOADOUT_ROOT below.
  • After expansion the path must be absolute; anchor home-relative sources with ~/.
  • Glob patterns must have a literal directory prefix to walk from: ~/dotfiles/**/*.lua is fine, a bare **/*.pem is rejected.
  • .. components are rejected wherever they appear.
  • A source path that does not exist on the host is dropped with a warning at activation rather than failing it, so opportunistically patching a dotfile tree the host may not have is safe. Other enumeration failures (permission denied, unreadable entries) still fail the composition.

dest is interpreted relative to the session user’s home directory. Absolute paths and .. components are rejected. For a literal (non-glob) source, dest is used verbatim as the destination file path; for glob sources, dest is the destination directory and each match’s path under the walk root is appended to it.

By default the walker does not follow symlinks while enumerating glob matches; see follow_symlinks and the client config.

Permissions carry across. A patched file lands with the source file’s own permission bits, so a script stays executable and a 0600 key stays readable only by you. Two qualifications:

  • Preservation is exact, which cuts both ways: a read-only source (a 0444 file, or a dotfile symlinked into a read-only store) lands read-only, and editing it in the session takes a chmod first.
  • setuid, setgid, and the sticky bit are dropped; the nine standard permission bits are what survives.

Ownership is not carried and cannot be: every file in the session belongs to the session user, whatever it was owned by on your host.

$LOADOUT_ROOT - Files shipped with the loadout

Not every file a loadout patches in belongs in your dotfiles. For the ones that exist only to serve this loadout, keep them beside it and name them with $LOADOUT_ROOT, which expands to a directory named after the loadout next to its .toml — the same directory its external hook scripts resolve against:

<config>/minimal/loadouts/
├── dev.toml
└── dev/
    ├── config.toml
    └── themes/
        └── nord.toml
patches = [
    { dest = ".config/helix/config.toml", source = "$LOADOUT_ROOT/config.toml" },
    { dest = ".config/helix/themes/",     source = "$LOADOUT_ROOT/themes/**/*.toml" },
]

It resolves against the loadouts directory actually in use, so a --config-dir or $XDG_CONFIG_HOME that moves your config takes the loadout’s files with it — which a hard-coded ~/.config/minimal/loadouts/dev/ would not.

Details worth knowing:

  • The reference is only available in the source of a patch a loadout declared. In a user policy pattern, or in a patch a project declared, it fails the activation rather than resolving to some other declarer’s directory. dest is a path inside the session, so it has no use for it either.
  • The name is reserved here: a loadout that also declares a LOADOUT_ROOT variable still patches from its own directory. The variable reaches the session normally — only patch sources ignore it.
  • $LOADOUT_ROOT alone names a directory, and a patch source matches files, so it patches in nothing. Write $LOADOUT_ROOT/**/* to take the whole tree.
  • The directory is optional. A loadout that never references it does not need one, and — like any other source — a path that isn’t there is skipped with a warning rather than failing the activation.

[[lifecycle_hooks]] - Scripts at session transition points

Optional

Each hook groups up to four scripts — on_activate when the session is created, on_destroy when it is destroyed, on_attach when you connect, and on_detach when you disconnect — and at least one must be present. An optional description labels the hook. Scripts are either inline or a path to a file:

[[lifecycle_hooks]]
description = "warm caches"
on_activate = { type = "inline",   value = "cargo fetch || true" }
on_detach   = { type = "external", value = "./cleanup.sh", timeout = 120 }

Each script may set a timeout in whole seconds, defaulting to 60 and capped at 300; a script that exceeds the cap is rejected when the file is parsed.

Scripts run under POSIX sh unless they open with a shebang, in which case they run under what it names — so a hook can be written in whatever you already think in:

[[lifecycle_hooks]]
on_activate = { type = "inline", value = "#!/usr/bin/env fish\nset -gx ...\n" }
on_detach   = { type = "external", value = "./teardown.py" }   # #!/usr/bin/env python3

The interpreter must be present in the session, and it is handed the script on standard input rather than as a file. That covers every shell and scripting language in common use; it does not cover one that insists on a file argument (awk -f). The shebang is parsed the way the kernel parses one — the interpreter is the first word, everything after it is a single argument (so #!/usr/bin/env -S python3 -u works, -S and all), and there is no quoting, so an interpreter path cannot contain spaces. Give an absolute path or a bare command for env to find: a relative path would resolve against the daemon, not the session. Default to POSIX sh where you can — it is the one interpreter a session is guaranteed to have.

External script paths must be relative (absolute paths and .. components are rejected at parse time). A loadout’s scripts are anchored at a directory beside the loadout file, named after the loadout — so dev.toml’s scripts live in dev/:

<config>/minimal/loadouts/
├── dev.toml
└── dev/
    ├── activate.sh
    └── teardown.sh

This is the same directory $LOADOUT_ROOT names, so a loadout’s scripts and the files it patches in live together.

A script must resolve to a regular file inside that directory. Symlinks are rejected rather than followed, at every path component — a symlink is the one way a path that passes every other check could still reach outside the anchor. All of this is checked on your machine before a session is created, so a mistyped path fails immediately and names the file.

Hooks from multiple contributors concatenate in declaration order, and run in that order for the setup transitions (on_activate, on_attach) and in reverse for the teardown ones (on_destroy, on_detach) — so a project sets up before your loadouts and tears down after them.

They execute inside the running session, sharing its packages, variables, files, and network. on_attach runs on the terminal you are attached to, so its output reaches you and [ -t 1 ] is true. The other three have no terminal and their output is captured into the daemon log — on_detach included, because the shell it would have run on is often the very thing that just went away (leaving a session by exiting its shell is a detach).

On top of the session’s own variables, each hook is given a few describing the run it is part of:

VariableValue
MINIMAL_SESSION_IDThe session’s id.
MINIMAL_SESSION_NAMEIts display name.
MINIMAL_HOOK_EVENTThe transition: on_activate, on_destroy, on_attach, or on_detach.
MINIMAL_HOOK_SOURCE_KINDloadout or project — for branching.
MINIMAL_HOOK_SOURCE_NAMEThe loadout’s name, or the project’s path as you refer to it on your own machine (not the daemon’s copy of the tree).
MINIMAL_HOOK_INDEX / MINIMAL_HOOK_COUNTPosition within this transition’s run, 1-based — for logging, e.g. [2/3].

Each teardown transition runs all of its hooks under a single shared time budget rather than giving each one its own timeout — on_detach and on_destroy get a budget each, not one between them. A session has to stay destroyable, and per-script caps alone would let enough hooks add up to hold one open. A hook that runs into the end of the budget is cut short and says so, and one the budget leaves no room for at all is reported as not run — either way the log distinguishes it from a hook that exhausted its own timeout, and teardown continues.

A failing on_activate fails the activation — the session does not become attachable, and the error names the hook and what it printed. The other three never block their transition: a failing attach, detach, or destroy hook is logged and the session carries on. A session must always be attachable and always destroyable.

on_failure was an earlier spelling that never executed; it has been removed. A hook file that still declares it is rejected with an error pointing at on_destroy.

Optional

Overrides the client-wide [loadouts].follow_symlinks setting (see client config) for this loadout’s patches only. When unset, the client-wide setting applies.

follow_symlinks = true

Selecting loadouts at activation

min session activate decides which loadouts to apply from two flags:

FlagDescription
--loadout <NAME>Apply <config>/minimal/loadouts/<NAME>.toml. Repeatable. If given, the config file’s default_loadouts are ignored
--no-loadoutsApply no loadouts at all. Conflicts with --loadout

Resolution order:

  1. --no-loadouts: nothing is applied, regardless of configuration.
  2. One or more --loadout NAME: exactly the named loadouts are applied.
  3. Neither flag: the [loadouts].default_loadouts list from the client config is applied.
  4. Neither flag and an empty default_loadouts: the built-in default loadout is applied, unless a user default.toml shadows it.

Loadouts are resolved and composed before the CLI contacts the daemon: a missing or malformed loadout file fails the activation loudly on the client rather than producing a silently-empty session. When loadouts are applied, the CLI prints Applying loadouts: <names> to stderr.

Activation is also when loadout contents are captured: the files are read once, inherited vars are resolved against the host environment, and the composed result is what the session runs with. Editing a loadout file does not change sessions that already exist; destroy and re-activate to pick up the edit.

Built-in default loadout

When a session is activated with no loadout flags and an empty default_loadouts, a built-in default loadout applies so a fresh box comes up oriented rather than in a bare shell. It contributes no packages — only a shaped PS1 and a once-only banner (the minimal mark, the orientation lines naming the session and its loadouts, plus a pointer to min add), shipped through the MOTD recipe. The banner is TTY-gated, prints exactly once per session, and renders without color.

It is the lowest-precedence source: --no-loadouts, --loadout, and a non-empty default_loadouts all take priority, and a user default.toml in the loadouts directory shadows it entirely (the file is applied in its place). min loadout list shows it as a default (built-in) row unless that user file is present.

Client config

Client-wide loadout preferences live in <config>/minimal/config.toml, under a [loadouts] section:

[loadouts]
default_loadouts = ["helix", "fish"]
follow_symlinks  = false
KeyDefaultDescription
default_loadouts[]Loadouts (by filename stem) applied to each new session when no --loadout/--no-loadouts flag is given
follow_symlinksfalseFollow symlinks while enumerating loadout patch sources. Turn on when your dotfile tree is a symlink farm (stow, chezmoi) and you want the walk to descend through the links

A missing file is equivalent to the defaults; unknown keys are rejected so a typo ([loadout] for [loadouts]) fails loudly.

Session keys

The detach chord is configurable. The leader key (the chord that enters command mode) and its command-mode subcommand keys live under a [session-keys] section in the same config.toml.

[session-keys] is client configuration, not a loadout: unlike everything a loadout composes, it is never baked into the session on activation. It is read fresh on every attach and negotiated per SSH channel, so each client — and each machine attaching to a session it didn’t create — brings its own chord. The section is documented here only because that is where config.toml is described.

[session-keys]
leader = "ctrl-]"
bell_on_leader = false

[session-keys.subcommands]
detach = "d"
forward = "ctrl-]"
KeyDefaultDescription
leaderctrl-]The chord that enters command mode, as a logical key name ("ctrl-]", "ctrl-^", "d", …). Rejected loudly at load if termios-special (ctrl-c, ctrl-w, ctrl-\, … — consumed by the line discipline before the app) or wrapping-ambiguous (ctrl-i = TAB, ctrl-m = CR, …)
bell_on_leaderfalseRing the terminal bell (BEL 0x07) on entering command mode. The terminal renders it per its own bell config; minimal picks no modality
subcommands.detachdThe command-mode key that detaches the channel
subcommands.forwardctrl-]The command-mode key that verbatim-forwards a leader byte down the PTY (for nested sessions). Defaults to the resolved leader, so a double-press forwards

Key names take one of two forms: ctrl-<glyph>, where the glyph is a single ASCII character in @..~ (so ctrl-2 and ctrl-? are rejected), or a single printable ASCII glyph such as d. Only ctrl- is configurable; alt-, shift-, meta-, and super- are rejected. The ctrl- prefix is case-insensitive and ctrl- letters normalise to lowercase, since Ctrl+a and Ctrl+A send the same control code. Plain glyphs are case-sensitive.

The leader is negotiated with the daemon per attach channel — sent as env vars alongside MINIMAL_SESSION_ID — so two clients with different configs on the same session each get their own chord. The daemon re-validates the leader as a silent backstop: a chord it rejects is logged and only that field falls back to the default — your valid detach/forward remaps survive — never garbling the screen. As with [loadouts], every field defaults and unknown keys are rejected, so an old config keeps parsing.

Two caveats on that per-channel model:

  • The banner’s detach hint is mint-scoped. The orientation banner prints MINIMAL_DETACH_HINT, seeded from the channel that minted the shell. A second client attaching with a remapped chord gets a working chord, but the banner still advertises the minting channel’s; trust your config over the banner in that case.
  • Bindings must not shadow each other. A detach that equals the leader or the forward key makes that other binding unreachable and is rejected at load; the daemon’s backstop reverts just the detach field.

Listing loadouts

min loadout list (alias: min loadout ls) enumerates every *.toml file in the loadouts directory, one row per file:

  NAME                DESCRIPTION                           CONTRIBUTES
* dev                 helix + zellij with my dotfiles       2 pkg / 4 var / 5 patch
  extra                                                     1 pkg / 0 var / 0 patch
  default (built-in)  orientation banner and shaped prompt  0 pkg / 3 var / 0 patch

  default (built-in) applied when no loadouts are configured

* default (from `[loadouts].default_loadouts`)

The two trailing lines are the legend min loadout list prints below the table: the first appears only when the built-in default is shown, the second only when default_loadouts is non-empty.

  • Loadouts named in default_loadouts are marked with a leading *.
  • The built-in default loadout is listed as a default (built-in) row unless a user default.toml shadows it.
  • Malformed entries are listed with their parse error so they can be fixed in place; a default_loadouts entry with no matching file produces a warning.
  • --dir <DIR> overrides the loadouts directory.

Composition, conflicts, and policy

At activation, the client composes the selected loadouts into a single contribution and ships it to the daemon, where it is merged with the project’s contribution (the [session] block of the project’s minimal.toml, plus per-package contributions) into the session’s final configuration. Merge semantics across all contributors:

  • Packages deduplicate: set semantics, there is no value to disagree on.
  • Vars with the same name and the same resolved value deduplicate. The same name with different values is a hard conflict that fails the composition; there is no override precedence between loadouts and the project. The error’s hint applies: add the name to your policy’s ignore list to drop all contributors of that variable.
  • Patches with the same destination and different sources are likewise a conflict.
  • Lifecycle hooks concatenate in declaration order; setup transitions run in that order and teardown transitions in reverse.

Two loadouts with the same name cannot be applied together.

Loadout contributions are gated by the user’s policy (<config>/minimal/user_policy.toml): items you declare yourself automatically pass the allow check, but the policy’s deny and ignore rules still apply: a loadout patch matching a deny pattern fails the composition on the client, before the daemon is involved. A missing policy file means an empty policy; a fresh install activates fine without it.

Vars in the attach shell

The interactive shell minted by min session attach is bash unless a loadout says otherwise — see SHELL below. bash is started as bash --noprofile --rcfile <daemon rc> -i: it sources none of your startup files (not /etc/profile, ~/.bash_profile, or ~/.bashrc), so rc-file patches cannot influence it — the only rc it reads is the daemon’s own, which installs the attached terminal hook and nothing else. Interactive setup travels through the environment instead, i.e. through [vars]:

  • Prompt: the session launcher seeds a baseline environment (a stock PS1, plus the orientation banner vars below) before merging in the composed vars, and a composed var overwrites a baseline entry with the same name. Setting PS1 in [vars] therefore replaces the stock prompt. This baseline is a layer beneath composition, not a contributor: the no-override conflict rule above arbitrates between contributors and does not apply to the launcher’s defaults.

  • Banner / MOTD: bash evaluates PROMPT_COMMAND from the environment before the first interactive prompt, so a once-only banner can ship as a payload var plus a trigger that clears its own payload:

    [vars]
    PROMPT_COMMAND = 'if [ -n "${MINIMAL_MOTD:-}" ]; then eval "$MINIMAL_MOTD"; unset MINIMAL_MOTD; fi; if [ -r "${MINIMAL_ATTACH_ENV:-}" ]; then . "$MINIMAL_ATTACH_ENV"; fi'
    MINIMAL_MOTD   = '''
    [ -t 1 ] && printf '%s\n' '' '  Welcome to the dev session.' ''
    '''

    The trigger clears MINIMAL_MOTD, so the banner prints exactly once and never runs for non-interactive commands; the [ -t 1 ] guard keeps redirected output clean. Multi-line literal values survive composition intact.

    Replacing PROMPT_COMMAND costs you nothing but the banner: the attached terminal’s TERM is refreshed by a hook the daemon installs, not by this variable.

SHELL - Which shell an attach starts

Set SHELL in [vars] to be dropped into that shell instead of bash:

packages = ["fish"]

[vars]
SHELL = "/usr/bin/fish"

Only the file name is read, so a value carried over from your host (/opt/homebrew/bin/fish, a Nix store path) still works — the path itself names a filesystem the session does not have. Five shells are supported, each because the session can install it and the daemon ships it an attached terminal hook:

SHELLPackage to install
bashbash (always present)
shbash — it ships sh as a POSIX-mode symlink, so this needs nothing
zshzsh
fishfish
nunushell

Anything else starts bash and prints one line saying why. A shell from that table which the session hasn’t installed is named along with the package that would supply it; a SHELL naming anything else is told which five shells there are to choose from. Nothing fails either way.

This is the loadout’s SHELL, not the $SHELL of the terminal you ran min from: a session is a declared environment, and the shell it hands you is part of the declaration.

Three things worth knowing:

  • bash is unchanged, rc suppression included. Every other shell reads its own startup files from the session home — which is where patches land, so a patched-in config.fish or .zshrc applies. That asymmetry is deliberate: bash’s behaviour predates this and stays as it was.
  • The shell is chosen once, at the attach that mints the session shell, and that shell outlives later attaches. Changing SHELL afterwards takes a new session.
  • The stock prompt follows where it can. The session default PS1 is written in bash’s syntax, so zsh is given the same prompt in zsh’s syntax instead of printing \u@\h at you; fish and nushell build their prompts from their own config and ignore PS1 entirely. A PS1 you set in [vars] always wins, in whatever syntax you wrote it.
  • The banner does not follow. The orientation banner rides PROMPT_COMMAND, which is bash’s; a fish or nushell session simply doesn’t print it. TERM refresh does work in all five — that is what the daemon’s hooks are for.

The attached terminal

A session shell is spawned once and outlives the terminals that attach to it, so TERM — a fact about the terminal currently attached — cannot live in its environment alone. The shell may have been minted for a different terminal, or for no terminal at all: a lifecycle hook or an exec brings the sandbox up the same way an attach does.

Nothing is required of a loadout or a project for this to work. The daemon publishes the current terminal’s facts on every attach, and installs the hook that re-reads them into every session rootfs, for every shell it supports. This is deliberately not carried by a [vars] entry: an environment variable is composed, so a loadout could replace it — and the MOTD recipe above unsets PROMPT_COMMAND outright — which would leave the session’s TERM depending on whether an author knew to preserve it.

The published files, rewritten on each attach (edits to them are lost):

VarFileRead by
MINIMAL_ATTACH_ENV~/.local/state/minimal/attach-env.shbash, zsh — POSIX export TERM='…'
MINIMAL_ATTACH_ENV_FISH~/.local/state/minimal/attach-env.fishfish — set -gx TERM '…'
MINIMAL_ATTACH_ENV_JSON~/.local/state/minimal/attach-env.jsonnushell — data, not a script

ENV is also set, to a daemon-owned file that sources the POSIX form at shell startup — see Other POSIX shells.

The variables are there for anything you want to script against. The hooks themselves do not read them: each hard-codes the path, so a composition that happens to set a variable of that name cannot redirect the refresh.

The hooks live at each shell’s own vendor/system integration point, inside the rootfs rather than the session home — so a loadout patch cannot collide with one:

ShellHook locationAt the promptBefore a command
bashthe rc the session shell is started with (--rcfile)DEBUG trap
zsh/etc/zsh/zshrcprecmdpreexec
fish/usr/share/fish/vendor_conf.d/fish_prompt eventfish_preexec event
nushell/usr/share/nushell/vendor/autoload/pre_promptpre_execution

Two hooks, because they answer different questions. The prompt one keeps a shell that is sitting idle current. The pre-execution one matters when you re-attach: the prompt on your screen was drawn before you detached, and it has already fired its prompt hook — so without a hook that runs before the command, the first thing you type at that restored prompt would still see the terminal you had last time, and only the next prompt would catch up. bash’s DEBUG trap covers both cases on its own.

bash uses a trap rather than PROMPT_COMMAND for the same reason the mechanism is not a var at all: PROMPT_COMMAND is composed, and the MOTD recipe unsets it. A trap is shell state, which nothing in a composition can reach.

Other POSIX shells

A plain sh, dash, the BusyBox or BSD ashes, ksh and mksh get a weaker guarantee, and the limit is POSIX’s rather than ours. The one hook POSIX defines is $ENV: an interactive shell expands it and sources that file at startup. The daemon points ENV at a file of its own (/usr/share/minimal/attach-env-posix.sh) which sources the published POSIX form, so any such shell starts with the terminal that is current at that moment — including one started from a parent whose own value was stale.

What these shells cannot do is refresh. POSIX has no per-prompt or per-command hook, and none can be built: PS1 expansion cannot change the shell’s own environment, because a command substitution runs in a subshell and ${var:=word} assigns only when the variable is unset. So a sh you left running across a re-attach keeps the terminal it started with. Two ways out, either of which is a one-liner:

. "$MINIMAL_ATTACH_ENV"   # re-sync this shell
exec sh                   # or just start a new one

ksh93 is the exception in this family — its PS1.get discipline function is a genuine per-prompt hook — but the daemon does not install one, so ksh93 behaves like the rest here.

Anything the daemon itself runs inside the session — an exec, a lifecycle hook — gets the current terminal’s facts applied directly, above the composed vars, without any of this.

Orientation banner

Unless a loadout overrides it, the first interactive prompt of an attached session prints a two-line orientation banner:

minimal · session api-server-4f2a · loadout default (built-in)
detach: ctrl-] then d · no minimal.toml here — min init to add one

The second line drops the min init pointer when the session workspace carries a minimal.toml (either layout, minimal.toml or .minimal/minimal.toml) — the template tests the workspace root (/workbench) in-shell at the moment it prints, so the clause reflects the session’s actual filesystem: it stays correct when an activation skipped the file upload, and disappears after an in-session min init once a fresh shell launches. The banner is TTY-gated, prints exactly once, and is plain text (NO_COLOR-safe).

It ships as a static template in the launcher baseline (the MOTD recipe above), interpolated by the shell at print time from two env vars every session carries:

VarValue
MINIMAL_SESSION_NAMEThe session’s name
MINIMAL_LOADOUTSDisplay list of the active loadouts: comma-joined names, default (built-in) for the zero-config fallback, none with --no-loadouts

Both are seeded daemon-side in the launcher baseline; the loadout list travels from the client as a first-class field on the composition (control-plane data, never a session var), so user vars and user policy cannot collide with either.

Because the trigger lives in the baseline layer, a loadout that sets its own PROMPT_COMMAND replaces the banner cleanly — and can interpolate the same $MINIMAL_* vars in its own MOTD, as the built-in default loadout does for its orientation lines. It carries no obligation beyond the banner — the attached terminal’s TERM is kept current by a daemon-installed shell hook that no composition can reach.

Last updated