Skip to content

feat(tmux): self-registering tmux hooks via managed block#48

Merged
rtalexk merged 30 commits into
mainfrom
feat/tmux-hook-self-registration
May 22, 2026
Merged

feat(tmux): self-registering tmux hooks via managed block#48
rtalexk merged 30 commits into
mainfrom
feat/tmux-hook-self-registration

Conversation

@rtalexk

@rtalexk rtalexk commented May 22, 2026

Copy link
Copy Markdown
Owner

Context

demux's tmux integration (done-state clearing, the sticky sidebar follow/slot
hooks) is delivered through tmux hooks. Until now those hooks were installed by
hand: demux hooks init --tool tmux printed a multi-line snippet the user
pasted into ~/.tmux.conf and re-sourced.

That snippet is a maintenance trap. Every time demux's hook set changes — a new
event, a corrected flag, an added run-shell -b — the pasted copy goes stale.
A stale snippet silently misbehaves (wrong flags expand to empty values) or
stacks duplicate hooks on re-source, and nothing tells the user to re-paste.
The hook set and the user's config drift apart with no feedback loop.

This PR closes that gap: the user runs one command once, and demux keeps its
own hook set current in every running tmux server from then on.

Scope is tmuxhooks install --tool only accepts tmux.

What does this PR do

Replaces the paste-a-snippet model with a self-registering managed block.

  • New demux hooks install --tool tmux writes a marker-delimited managed block
    to ~/.tmux.conf containing a single bootstrap line:
    # >>> demux managed block >>>
    set-hook -g client-attached "run-shell -b 'demux event client_attached 2>/dev/null; true'"
    # <<< demux managed block <<<
    
    It also registers the hook set in the live server immediately. The write is
    atomic (temp file + rename) and backs up the prior file to ~/.tmux.conf.demux-bak.
  • On every tmux client attach, the bootstrap fires demux event client_attached,
    which reconciles demux's full canonical hook set into the running server:
    clears each event, re-adds the user's own (non-demux) entries, then re-adds
    demux's — idempotent, never stacks duplicates, self-heals after a demux upgrade.
    A version gate (@demux_hooks_version, a hash of the desired set) makes the
    steady-state attach a single tmux query.
  • demux hooks install --print-only prints the bootstrap line without touching
    any file or server.
  • Re-running hooks install is idempotent (replaces the block in place). If an
    older pasted multi-line snippet is detected outside the managed block, the user
    is prompted to remove it.
  • New demux event subcommands name the tmux event rather than the reaction:
    window_focus, session_changed, new_window, client_attached. The hook
    handler decides the reaction (clear done-states, move the sidebar, reconcile
    slot panes).
  • New config [sidebar.sticky] auto_show — when true, the sticky sidebar is
    shown automatically on client attach. Default false.
  • Group commands (demux event, …) now reject an unknown subcommand with an
    error on stderr and a non-zero exit, instead of cobra's default of printing
    help to stdout with exit 0. Hooks run demux … 2>/dev/null; a since-removed
    subcommand now fails silently rather than dumping help text into a pane.
  • demux hooks init is kept as a hidden, deprecated alias for
    hooks install --print-only.

Out of scope

  • Tools other than tmux. hooks install --tool rejects anything but tmux;
    there is no shell/zsh integration in this iteration.

Manual QA

CLI feature. Verify by building the binary and driving demux hooks /
demux event against a tmux server, observing ~/.tmux.conf content and
tmux show-hooks -g output.

⚠️ demux hooks install mutates real state — ~/.tmux.conf and the live
tmux server.
Do not run it bare during QA. Every scenario below runs the
binary against an isolated tmux server (tmux -L demuxqa) and a temp
$HOME
, so your real config and real tmux server are never touched. The
$TMUX override is what redirects demux's tmux calls to the isolated server —
if you omit it, an empty/unset $TMUX routes to your default server.

Prerequisites

1. tmux 3.x+ is installed.
tmux -V

Expect tmux 3.x or next-3.x. The reconcile path was verified on next-3.7.

2. Go toolchain available to build the binary.
go version

Expect go1.2x. If missing, install Go or use a prebuilt demux binary from this branch.

Setup

Build the binary and stand up the isolated server + temp $HOME. Run this once;
later scenarios reuse $D, $QAHOME, $QATMUX.

mkdir -p /tmp/demux-qa && go build -o /tmp/demux-qa/demux .
export D=/tmp/demux-qa/demux
export QAHOME=$(mktemp -d /tmp/demux-qa-home.XXXXXX)

HOME="$QAHOME" tmux -L demuxqa new-session -d -s qa -x 200 -y 50
SOCK=$(tmux -L demuxqa display-message -p -t qa '#{socket_path}')
PID=$(tmux -L demuxqa display-message -p -t qa '#{pid}')
SID=$(tmux -L demuxqa display-message -p -t qa '#{session_id}')
export QATMUX="$SOCK,$PID,$SID"

# baseline: isolated server has no demux hooks
tmux -L demuxqa show-hooks -g | grep -c demux   # expect: 0
Scenario 1 — `--print-only` prints the bootstrap line, touches nothing
  1. Run print-only:
    HOME="$QAHOME" $D hooks install --print-only
    Expected stdout, exit 0:
    set-hook -g client-attached "run-shell -b 'demux event client_attached 2>/dev/null; true'"
    
  2. Confirm no file was written:
    ls "$QAHOME/.tmux.conf"
    Expected: No such file or directory.
Scenario 2 — `hooks install` writes the managed block and registers live hooks
  1. Install into the isolated env:
    env HOME="$QAHOME" TMUX="$QATMUX" $D hooks install --tool tmux
    Expected stdout: Created <QAHOME>/.tmux.conf and
    Registered demux hooks in the live tmux server., exit 0.
  2. Inspect the file:
    cat "$QAHOME/.tmux.conf"
    Expected: exactly the 3-line managed block (begin marker, set-hook -g client-attached …, end marker).
  3. Inspect the live server:
    tmux -L demuxqa show-hooks -g | grep demux
    Expected: 7 lines — client-attached (bootstrap) plus after-kill-pane,
    after-new-window, after-select-pane, after-select-window,
    client-focus-in, client-session-changed.
Scenario 3 — re-running `hooks install` is idempotent
  1. Run install a second time:
    env HOME="$QAHOME" TMUX="$QATMUX" $D hooks install --tool tmux
    Expected stdout: Updated <QAHOME>/.tmux.conf (backup: …/.tmux.conf.demux-bak).
  2. File is still a single 3-line block:
    wc -l < "$QAHOME/.tmux.conf"          # expect: 3
    ls "$QAHOME/.tmux.conf.demux-bak"     # backup exists
  3. No duplicate hooks on the server:
    tmux -L demuxqa show-hooks -g | grep demux | sed -E 's/\[[0-9]+\].*//' | sort | uniq -c
    Expected: every event shows count 1.
Scenario 4 — legacy pasted snippet is detected and removed on confirm
  1. Stage a temp $HOME with an old-style snippet plus user-owned config:
    LH=$(mktemp -d /tmp/demux-qa-legacy.XXXXXX)
    printf '%s\n' \
      '# my own tmux config' \
      'set -g mouse on' \
      'set-hook -g after-select-pane "run-shell '\''demux event pane_focus 2>/dev/null; true'\''"' \
      'set-hook -ga client-session-changed "run-shell '\''demux sidebar follow'\''"' \
      'bind r source-file ~/.tmux.conf' > "$LH/.tmux.conf"
  2. Install, answering y at the prompt:
    printf 'y\n' | env HOME="$LH" TMUX="$QATMUX" $D hooks install --tool tmux
    Expected: lists 2 legacy demux hook line(s) at L3/L4, prints
    Removed 2 legacy line(s).
  3. Verify only the legacy hook lines were removed:
    cat "$LH/.tmux.conf"
    Expected: # my own tmux config, set -g mouse on, bind r … all retained;
    the two set-hook … demux … lines gone; managed block appended.
  4. Cleanup: rm -rf "$LH".
Scenario 5 — end-to-end: a real client attach self-registers the full hook set
  1. Clear the isolated server's hooks and source only ~/.tmux.conf (simulating
    a fresh server start that loads just the managed block):
    for ev in after-select-pane after-select-window client-session-changed \
              client-focus-in after-kill-pane after-new-window pane-exited \
              client-attached; do tmux -L demuxqa set-hook -gu "$ev" 2>/dev/null; done
    tmux -L demuxqa set -gu @demux_hooks_version 2>/dev/null
    tmux -L demuxqa source-file "$QAHOME/.tmux.conf"
    tmux -L demuxqa show-hooks -g | grep demux     # expect: only client-attached
  2. Run the reconcile entry point directly (this is exactly what the bootstrap
    hook invokes on attach):
    env HOME="$QAHOME" TMUX="$QATMUX" $D event client_attached
  3. Verify the full canonical set is now registered:
    tmux -L demuxqa show-hooks -g | grep demux
    tmux -L demuxqa show-hooks -g pane-exited
    tmux -L demuxqa show -gv @demux_hooks_version
    Expected: 7 event hooks present (including pane-exited, which the bare
    show-hooks -g dump omits — query it explicitly), and @demux_hooks_version
    set to a 12-hex-char hash.
  4. Re-run demux event client_attached — the version gate makes it a no-op;
    show-hooks -g is unchanged, no duplicates.
Scenario 6 — reconcile preserves the user's own hooks (incl. `pane-exited`)
  1. Seed a user-owned hook on pane-exited and on after-select-pane:
    tmux -L demuxqa set-hook -ga pane-exited 'run-shell "echo USER-pane-exited"'
    tmux -L demuxqa set-hook -ga after-select-pane 'run-shell "echo USER-select-pane"'
    tmux -L demuxqa set -gu @demux_hooks_version
  2. Reconcile:
    env HOME="$QAHOME" TMUX="$QATMUX" $D event client_attached
  3. Verify both user hooks survived at index [0], demux's at [1]:
    tmux -L demuxqa show-hooks -g pane-exited
    tmux -L demuxqa show-hooks -g after-select-pane
    Expected, for each event: [0] = the echo USER-… hook, [1] = the
    demux event … hook. (This is the case fixed by commit 643f13e — the bare
    show-hooks -g dump omits pane-exited, so reconcile queries each event
    individually to avoid wiping the user's hook.)
Scenario 7 — unknown subcommand fails on stderr, bare parent prints help
  1. Unknown subcommand:
    HOME="$QAHOME" $D event bogus; echo "exit=$?"
    Expected: Error: unknown command "bogus" for "demux event" on stderr,
    exit=1, empty stdout.
  2. Bare parent still prints help:
    HOME="$QAHOME" $D event; echo "exit=$?"
    Expected: help text on stdout, exit=0.

Teardown

tmux -L demuxqa kill-server
rm -rf "$QAHOME" /tmp/demux-qa /tmp/demux-qa-home.* /tmp/demux-qa-legacy.*

-L demuxqa is a dedicated test server created in Setup — killing it does not
affect your real tmux server.

rtalexk added 27 commits May 21, 2026 22:32
Introduces internal/tmuxhooks with DesiredHooks() (the canonical set of
demux-owned tmux hook entries, excluding the client-attached bootstrap)
and HooksHash() (a stable 12-hex-char SHA-256 digest used as a staleness
key in @demux_hooks_version). Both the runtime reconciler and the upcoming
`demux hooks install` command will share this single source of truth.
Introduces the Tmux interface and its real shell-out implementation.
Run wraps exec.Command with stderr capture for richer error messages;
Output does the same and returns stdout. Tests substitute a fake.
Add parseShowHooks to group raw `tmux show-hooks -g` output by event
name, and isDemuxEntry to classify whether a hook command was registered
by demux. Includes TDD test coverage for both functions.
Implements the core Reconcile function that idempotently registers
demux's desired hook set, preserving user-owned hooks. Adds
CurrentVersion, MarkVersion, and Sync as the fast-path entry point
that skips reconciliation when the stored hash matches HooksHash().
… limit

Remove the intermediate newEntries variable in Reconcile by inlining the
append directly into the range clause. Add a test case to TestIsDemuxEntry
documenting that user hooks whose text happens to contain "demux sidebar "
or "demux event " are a known false-positive of the substring classifier.
Adds `demux event client_attached`, fired by the demux managed-block
bootstrap hook on tmux client-attached. The command reconciles demux's
tmux hook set under the event lock, auto-shows the sticky sidebar when
cfg.Sidebar.Sticky.AutoShow is set, and warns when legacy pasted hook
lines are detected in tmux.conf. Adds a registration test in
cmd/event_test.go.
Add AutoShow bool field (toml:"auto_show") to SidebarStickyConfig.
When true, the sticky sidebar appears automatically on tmux client attach
(consumed by the client_attached command). Defaults to false via Go zero value.

Update the config-init template (cmd/config.go) and README configuration
reference to document the new field.
Implement conffile.go with functions to manage a marker-delimited "demux
managed block" in ~/.tmux.conf. Provides LoadConf (symlink-resolving),
WithManagedBlock (idempotent insert/replace), SaveConf (atomic write +
backup), plus BootstrapHook and BootstrapCommand constants for the
client-attached hook registration.
Add DetectLegacyHooks to identify old-style demux set-hook lines that
linger outside the managed block, and RemoveLines to strip them by
1-based line number. Closes the cross-task build gap: cmd/event.go now
has all its tmuxhooks dependencies and go build ./... succeeds.
Replace `hooks init` (print-and-paste snippet) with `hooks install`, which
writes a one-line managed block to ~/.tmux.conf and registers demux's full
hook set in the live tmux server via the internal/tmuxhooks package.
`hooks init` is kept as a hidden deprecated alias. Updates README setup
section and replaces test suite to cover promptYesNo and command registration.
Replace three stale references to `demux hooks init --tool tmux` and the
old paste-snippet workflow with accurate descriptions of the new
self-registration model (`demux hooks install`):

- slots-mode paragraph: drop snippet reference, say "demux's tmux hooks"
- ## Hooks > ### Tmux: replace generate/paste/CAUTION/snippet block with a
  short description pointing to the Enable-the-tmux-hooks setup section
- command cheatsheet: swap hooks-init line for hooks-install
Register a no-op --tool flag on hooksInitCmd so that the old invocation
`demux hooks init --tool tmux` continues to work instead of erroring with
"unknown flag: --tool".

Also restore the inline comment on the TMUX_PANE assignment in
tmuxCurrentIDs explaining the %N pane-ID format.
Update the "Tmux hooks not clearing done states" section to reflect the
self-registration model: demux now registers hooks automatically on every
client-attached event via the bootstrap hook, not via a static .tmux.conf
snippet. Lists all 6 registered events (was incorrectly stated as four),
and replaces the old `tmux source ~/.tmux.conf` recovery step with
`demux event client_attached`.
Add TestReconcileSkipsEventWhenClearFails to verify that when clearing one
event's hook array fails (e.g. an unsupported hook name), Reconcile logs
and continues rather than returning an error — and that subsequent events
still reconcile successfully.
Lift the body of sidebarSlotsEnsureCmd.RunE into a standalone
ensureSlots() function so the upcoming event new_window handler can
call it directly. The command becomes a thin wrapper; behaviour is
unchanged.
Implements three new event subcommands that absorb sidebar-follow and slots-ensure behavior:
- window_focus: fired by after-select-window hook, clears done-states and follows sidebar
- session_changed: fired by client-session-changed hook, clears done-states and follows sidebar
- new_window: fired by after-new-window hook, follows sidebar and reconciles slots

Both runWindowFocus and new_window handlers are best-effort; failures are logged, not returned.
Use cmd.Use instead of the hardcoded "window_focus" prefix in the
shared runWindowFocus handler so the log message is accurate when
the handler runs as session_changed.

Also append "; auto-detected if omitted" to the --pane-id, --window-id,
and --session-id flag descriptions for window_focus and session_changed,
matching the existing pane_focus flag help text.
Replace the 9-entry DesiredHooks() set (which called sidebar follow /
slots ensure directly) with 7 entries that each call a `demux event …`
subcommand. window_focus and session_changed get dedicated event
commands; after-new-window now calls `demux event new_window`. Update
test expectations to match the new cardinality and command strings.
The tmux hooks now route through `demux event …` handlers instead of
calling `demux sidebar follow` or `demux sidebar slots ensure` directly.
Delete both hidden cobra commands and their tests; keep ensureSlots()
which is still called by the event handler.
A non-runnable cobra parent command (event, sidebar, sidebar slots) printed
its help to stdout with a zero exit when given an unknown subcommand. A tmux
hook calling a since-removed subcommand (e.g. a stale `demux sidebar follow`
left over before a reconcile) therefore dumped help text into the pane: the
hook's `2>/dev/null` only silences stderr.

rejectUnknownSubcommand gives these group commands a RunE that returns an
error for an unknown subcommand (cobra prints it to stderr, exit 1) and still
prints help when invoked bare. Stale hooks now fail silently under 2>/dev/null.
…vents

The bare `tmux show-hooks -g` dump omits some events (e.g. pane-exited on tmux
3.x), causing those events' user hooks to be silently destroyed during reconcile.
Query each event individually with `show-hooks -g <event>` so all user hooks
are visible and preserved.

Also add test infrastructure: scriptShowHooks helper and regression test
TestReconcilePreservesUserHookForPerEventQuery.
Add a callout to the "Enable the tmux hooks" section for users on a
previous demux: the multi-line snippet pasted from `demux hooks init` is
obsolete, and `demux hooks install` detects and offers to remove those
legacy lines in favor of the managed block.
@rtalexk rtalexk force-pushed the feat/tmux-hook-self-registration branch from 0f5d97c to 1cb076f Compare May 22, 2026 19:04
rtalexk added 2 commits May 22, 2026 12:27
Split the 63-line hooksInstallCmd.RunE into focused helpers:
- reviewLegacyHooks: detects, reports, and removes old-style pasted lines
- registerLiveHooks: registers bootstrap hook and reconciles live server

RunE now reads top-down: validate tool → load config → review legacy →
write managed block → register live hooks. Behavior unchanged.
Replace 3-deep if-err==nil pyramid in eventClientAttachedCmd.RunE with
warnLegacyTmuxHooks() helper that uses guard-clause early returns.

Behavior unchanged; legacy detection moved to separate concern.
Remove the BootstrapCommand() func that only wrapped a private const.
Promote bootstrapCommand to exported const BootstrapCommand, eliminating
one level of indirection. Update callsite in cmd/hooks.go.

Behavior unchanged; const access is cleaner than a getter.
@rtalexk rtalexk merged commit 8152964 into main May 22, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant