Single-binary tmux session manager: declare sessions in YAML, detect drift between live state and config, reload dotfiles across all sessions. Full TUI dashboard, sesh-style fuzzy picker. No plugins, no telemetry.
Русская версия — README.ru.md.
picker — bare tmh opens a fuzzy session picker
tour — full TUI: help overlay, tree navigation, palette, new-session wizard, settings, theme cycle, kill + undo, history
workflow — declare in YAML, tmh init, introduce drift, tmh diff detects it, tmh freeze captures it back
tmh exists because the zsh aliases around tmux stop scaling around
session #5. nine tm-* aliases, one ini file, no diff, no undo, no
sharing. tmh is the single-binary replacement: one config.yml, one
tool, and a tmh diff that tells you what drifted so you don't find
out by accident.
One unique feature: tmh reload --shell sources the correct rc file in every
live session instantly — no manual re-sourcing after dotfile changes.
| Tool | Language | Config | Drift Detection | Dotfile Sync | Single Binary |
|---|---|---|---|---|---|
| tmh | Go | YAML | ✓ | ✓ | ✓ |
| tmuxinator | Ruby | ERB DSL | — | — | — |
| tmuxp | Python | YAML/JSON | — | — | — |
| sesh | Rust | CLI flags | — | — | ✓ |
- Install
- First run
- Quick start
- UI language
- Config —
config.yml - CLI reference
- TUI dashboard
- Picker (bare
tmh) - Process visibility
- Marks and last-location
- tmux integration
- Hooks and trust
- Snapshots and undo
- Sharing with a teammate
- Discovery rules (glob + zoxide)
tmh freeze— capture live into YAML- JSON schema and editor integration
- Security model
- Troubleshooting
- Architecture
- License
go install github.com/mark1708/tmh/cmd/tmh@latestRequires Go 1.25+ (the embedded modernc.org/sqlite driver raises the
floor).
brew install mark1708/tap/tmhThe formula installs the binary, man pages, and bash/zsh/fish completions.
git clone https://github.com/mark1708/tmh.git
cd tmh
go build -o ~/.local/bin/tmh ./cmd/tmhtmh version
tmh doctordoctor checks:
- tmux ≥ 3.2,
$SHELL,config.yml(existence + schema); - tmux-server reachability, optional
fd,terminal-notifier(macOS only); - a separate tmux integration block audits server options
(
default-terminal,mouse,escape-time,extended-keys,base-index,pane-base-index,renumber-windows), conflicting hooks (after-new-window,automatic-rename=on), and the presence of#(tmh status)instatus-right. Every ⚠/✗ finding prints a ready-to-paste line for~/.tmux.conf.
Binary-release downloads include a GPG-signed checksums.txt; verify
via gpg --verify checksums.txt.sig checksums.txt — full guide in
docs/verify.md.
If ~/.config/tmh/config.yml is missing, tmh offers four options
interactively (when stdin is a TTY):
- start empty — minimal config with
version: 1and empty sections. - import from live tmux — runs
sync --pull --bootstrap, auto-derivesroots:via a longest-common-prefix scan of every session's first-pane cwd, then imports every live session undersessions:. - import from file / URL — read a teammate's YAML into the new config location.
- quit.
Option 2 is the path of least resistance if tmux is already running — you get a honest YAML with every window captured:
tmh sync --bootstrapAfter that, cat ~/.config/tmh/config.yml shows inferred roots: and
sessions: keyed by name, each window expressed as {root: <key>, path: <relative>} when it fits a root.
In non-TTY mode (pipe, CI) an empty config is created silently and tmh
keeps working — ls, attach, kill, reload --shell, popup,
scratch, and window all tolerate a missing config (pass-through
mode).
Every tmh write prepends a # yaml-language-server: $schema=… header
so any editor with the YAML language server (VS Code, Helix, Neovim)
picks up autocomplete and inline validation automatically — see
JSON schema and editor integration.
# Import live tmux into config.yml.
tmh sync --bootstrap
# After a reboot — bring everything back with one command.
tmh init
# What's declared and what drifted?
tmh ls
tmh diff
# Switch windows.
tmh attach epcp:lk # outside tmux → attach-session
# inside tmux → switch-client
# Sync dotfiles into live sessions (a killer feature).
tmh reload --shell # source the right rc file in every idle pane
tmh reload --shell --busy # …and queue busy panes for later
tmh reload --tmux # tmux source-file ~/.tmux.conf
tmh reload --all # both at once
# Capture a live layout you built by hand into the YAML.
tmh freeze
# Bare command — quick picker (TTY + tmux running); --dashboard for the full TUI.
tmh
tmh --dashboardtmh reload --shell picks the right rc file automatically — bash →
~/.bashrc, fish → ~/.config/fish/config.fish, zsh → ~/.zshrc.
The --rc <path> flag overrides the auto-detection.
English (default) and Russian are bundled. Unsupported locales
(de_DE, ja_JP, …) silently fall back to English — users never see
raw i18n keys.
Resolution order (highest wins):
--lang en|ru— a global flag that overrides everything. Affects runtime output (toasts, errors,fmt.Print*lines). Cobra help text is bound at startup and is not retranslated by--lang— that's a cobra limitation.defaults.lang: ruinconfig.yml.- Environment variables
TMH_LANG,LC_ALL,LC_MESSAGES,LANG(the prefix before_/.is consulted). - Fallback — English.
Live switching from the TUI: S (settings) → Appearance section →
↑↓. The change applies immediately and persists as defaults.lang.
JSON outputs (tmh ls --json, tmh diff --json, tmh tmux audit --json) stay English regardless of locale — they're a stable scripting
contract. Drift exposes a stable ReasonCode field (e.g.
session_gone) that the TUI resolves to a localised string at
render-time.
Lives at ~/.config/tmh/config.yml (or wherever $TMH_CONFIG or
--config points). YAML with structural references — no Mustache, no
templating DSL.
# yaml-language-server: $schema=https://raw.githubusercontent.com/mark1708/tmh/main/schemas/tmh.schema.json
version: 1
# Named root directories so long prefixes aren't repeated.
roots:
work: ~/work/orgA
home: ~/work/personal
kb: ~/work/personal/kb/bases
# Global fallbacks applied when a deeper level leaves a field unset.
defaults:
layout: 3-pane
shell: zsh
lang: en # en | ru; empty → auto-detect from env
popup: {width: 80%, height: 60%}
env:
EDITOR: nvim
# Reusable window templates. `extends:` only references templates —
# chains are rejected at validation (ErrTemplateChain).
templates:
kb_base:
layout: 2-pane
command: nvim .
# Custom tmux layout hashes for experimental layouts.
# Capture your own: arrange the window, then `tmh layout save <name>`.
layouts:
my-ide:
hash: "5c3b,239x56,0,0{119x56,0,0,0,..."
description: "editor left 50%, stacked panes right"
# Profiles — filter sessions by group + optional env/defaults overlay.
profiles:
work:
groups: [work, orgA]
env: {AWS_REGION: eu-central-1}
personal:
groups: [home, kb]
# Discovery — auto-generate candidate sessions from the filesystem
# (and optionally zoxide). Entries appear in `tmh ls` and the picker;
# they become real sessions on attach.
discover:
- path: ~/work/orgA/services/*
template: go_service
zoxide: true
zoxide_limit: 15
# Declared sessions.
sessions:
epcp:
group: [work, orgA]
root: work
path: products/epcp/repos
env:
KUBE_CONTEXT: epcp-dev
AWS_PROFILE: epcp
on_attach:
- mise use
windows:
# shorthand: bare string = {dir: <value>}, relative to root
lk: lk-mosru-epcp
mdr: mdr
filings: filings
# full form with template + command
kb:
extends: kb_base
root: kb
path: epcp
# window-scoped hooks fire in addition to session-scoped ones
on_create:
- make depswindows:
<name>:
dir: string # absolute or relative
root: string # key from roots.<...>
path: string # alternative to dir when rooted
layout: string # 1-pane | 2-pane | 3-pane | <layouts.<key>>
command: string # command for the main pane
extends: string # key from templates.<...>
env: {KEY: VALUE} # env overrides
focus: bool # active window after init
hooks: # window-scoped hooks (see Hooks section)
on_create: [...]
on_attach: [...]
on_destroy: [...]
panes: # explicit pane layout
- dir: ...
command: ...
env: {}
focus: true
hooks: {...} # pane-scoped hooksShort form name: "string" is equivalent to name: {dir: "string"}.
- Absolute
dir:→ used as-is. root:set →roots[root] / (path || dir).session.rootset +dir:relative →roots[session.root] / session.path / dir.- Otherwise →
$PWD / dir.
Optional shorthand: a string starting with $key/... expands to
{root: key, path: ...}. $$ is a literal $. The shorthand is
normalised in-memory on load (config.Normalize); there's no CLI
wrapper yet for persisting a normalised version to disk.
Deeper levels override:
defaults.env
→ profiles[active].env
→ sessions[x].env
→ sessions[x].windows[y].env
→ sessions[x].windows[y].panes[z].env
Maps are merged key-by-key, not replaced wholesale.
tmh doctor validates the schema and prints
config.yml schema: <err> if anything is off. Checked:
- every
root:resolves to a declaredroots.<key>(ErrUnknownRoot); - every
extends:resolves totemplates.<key>(ErrUnknownTemplate); extendsdepth is exactly 1 (ErrTemplateChain);- every
layout:is a built-in or a declaredlayouts.<key>(ErrUnknownLayout); panes[]count is compatible with built-in layouts (ErrLayoutMismatch).
Global flags available on every subcommand:
--config string path to config.yml (overrides $TMH_CONFIG and defaults)
--profile string profile name from config.yml
--lang en|ru UI language; overrides config and env
tmh open the TUI dashboard (or the picker — see below)
tmh --dashboard force the full TUI, bypassing the picker
tmh version print the version
tmh doctor environment + tmux-integration audit
tmh completion {zsh|bash|fish} completion script
tmh attach [name|name:window] attach (outside tmux) / switch-client (inside)
tmh new [--name] [--dir] [--layout] [--group] [--save] [--attach]
without flags — interactive wizard (huh form)
tmh init [--only a,b] bring up everything from config (skip existing)
tmh kill <pattern> kill sessions matching a substring
tmh ls [--json] sessions/windows tree
tmh window [--dir] new ad-hoc window in the current session
tmh scratch [--dir] ephemeral session
tmh ps table of every pane: session/window/pane/cmd/pid/cwd
tmh ps --session <name> restrict to one session
tmh ps --format json|tsv machine-readable output (json is pipe-friendly natively)
Example:
SESSION WINDOW PANE CMD PID CWD
work editor 0 nvim 12345 ~/work/myproject/src
work server 0 go 12346 ~/work/myproject
kb main 0 zsh - ~/kb
tmh sync --push live ← config (create missing sessions/windows)
tmh sync --pull [--all] config ← live (add new, update drift)
tmh sync --bootstrap import every live session into an empty config
tmh sync --dry-run print planned changes without applying them
tmh diff [--json] print drift entries
tmh freeze [--session <name>] [--dry-run]
non-destructive live → YAML capture; see
"tmh freeze" section below
Drift statuses:
| Status | Meaning |
|---|---|
ok |
window is identical in live and config (root/dir match) |
drift |
first pane's pane_current_path ≠ resolved dir |
new |
window appeared live inside a tracked session, absent in config |
gone |
window declared in config but not running |
tmh reload (default --all) shell + tmux
tmh reload --shell source the right rc file in idle shell panes
tmh reload --tmux tmux source-file ~/.tmux.conf
tmh reload --busy non-idle panes are queued; sourced when they free up
tmh reload --status show the deferred queue
tmh reload --rc <path> override the rc path (otherwise derived from $SHELL)
tmh reload --tmux-conf <path> override the tmux conf path
tmh watch [--auto] fsnotify watcher on the dotfiles
tmh status single-glyph segment for tmux status-right
tmh snapshot save <name> named checkpoint of live state
tmh snapshot list
tmh snapshot restore <name>
tmh snapshot delete <name>
tmh undo revert the last destructive action
tmh export [--minimal] [--only <name>] YAML to stdout; --minimal redacts secrets
tmh import <path> --merge|--replace
tmh layout save <name> [--description] save the active window layout
tmh popup <cmd> [--width] [--height] [--no-env] [--no-cwd] [--session] [--window]
command in a tmux popup with env/cwd from config
tmh tmux audit [--json] print audit findings for the tmux server
tmh tmux setup [--append] snippet for ~/.tmux.conf; --append adds a managed block
tmh --dashboard explicit full dashboard
tmh picker first; dashboard on fall-through
┌─ tmh · ~/.config/tmh/config.yml ──── ⚠ drift:2 ──────────────────┐
│ SESSIONS │ DETAIL │
│ ▼ ● epcp 7w ok │ session: epcp │
│ ├─ ● lk 3p ok │ live ✓ │
│ ├─ mdr 3p ok │ attached ✓ │
│ ├─ ! jr 3p drift │ windows 7 │
│ └─ … │ status ok │
│ ▼ ● kb 8w │ │
│ │ preview │
│ │ $ mise use │
│ │ $ git status │
├──────────────────────────────────────────────────────────────────┤
│ a · n · d · R · s · S · : · ^L · ? · q [ OK reload done ]│
└──────────────────────────────────────────────────────────────────┘
Layout features:
- Boolean detail fields (
live,attached) render as ✓/✗. - Below the detail fields — an async preview (
tmux capture-paneof the focused session/window's first pane). Refreshed on cursor change; cache keyed by target. - Inline toasts attach to the right side of the footer for 4–5 s
(errors 5 s, action-done 4 s). Every toast also enters the history
ring (last 30), accessible via
Ctrl+L.
Navigation
| Key | Action |
|---|---|
j / k / ↑↓ |
up / down |
h / l |
collapse / expand session |
Tab on a window row |
toggle inline pane rows |
ShiftTab on a window row |
cycle preview between panes |
/ |
inline tree filter (Enter keeps, Esc clears) |
g / G |
top / bottom |
PgUp / PgDn |
page |
Actions
| Key | Action |
|---|---|
enter / a |
attach (tmux takes the TTY; return via prefix d) |
n |
new session via the wizard |
d |
kill session / window / pane (context-aware, confirmed) |
u |
undo the last destructive action |
m<a> |
set mark a on the current position |
'<a> |
jump to mark a |
'' |
return to the previous position (last-location) |
Sync / reload
| Key | Action |
|---|---|
r |
refresh the TUI tree |
R |
source <rc> + tmux source-file |
s |
sync --push (create missing entries) |
D |
drift screen |
Other
| Key | Action |
|---|---|
: / Ctrl+P |
command palette (fuzzy + parametric actions) |
S |
settings |
Ctrl+L |
action history with OK/ERR badges |
Ctrl+T |
cycle the theme |
? |
contextual help (screen-specific) |
q / Ctrl+C |
quit |
Seven categories in a master-detail layout (left column — categories, right column — fields):
| Category | What it tunes |
|---|---|
| Appearance | theme (Catppuccin variants), language (en/ru) |
| Display | show processes in tree, footer heatmap, default preview pane |
| History | retention (7d/30d/90d/forever), max entries, clear |
| Marks | persist_across_sessions, reset all marks |
| Tmux | escape-time, mouse, base-index — writes to ~/.config/tmh/tmux.conf |
| Behaviour | auto-refresh interval, dry_run_default, confirm_on_kill |
| Keybindings | read-only quick reference |
Live-apply: theme, language, display fields apply instantly. Tmux
fields need Ctrl+S to save.
: or Ctrl+P. Fuzzy search + parametric actions:
| Action | Description |
|---|---|
mark: set mark |
set a named mark (prompts for a letter) |
goto: jump to process |
jump to the first pane with the given command (prompts for a name) |
attach <session> |
one entry per live session |
| data refresh, sync, init, diff, snapshot, undo, doctor, … | standard actions |
Parametric actions surface an extra input field before execution. Esc
cancels back to the selection list.
On d (kill):
y/Enter— executen/Esc— cancelt— dry-run: show what would be killed without touching anything
When stdin and stdout are both TTYs and a tmux server is reachable,
bare tmh routes to a compact fuzzy picker instead of the full TUI
dashboard. This is the sesh-style muscle memory: type a few letters,
Enter attaches.
┌ tmh — pick a session ─────────────────────────────────────┐
│ │
│ api attached │
│ web live │
│ infra configured │
│ scratch discovered ~/work/scratch │
│ notes discovered ~/work/personal/notes │
│ │
└────────────────────────────────────────────────────────────┘
↑/↓ move · / filter · enter attach · d dashboard · esc cancel
Keys:
| Key | Action |
|---|---|
↑ / ↓ / j / k |
move |
| type letters | fuzzy filter |
Enter |
attach (outside tmux) or switch-client (inside); discovered candidates are created first |
d / ? |
fall through to the dashboard |
Esc / Ctrl+C |
cancel without attaching |
The picker falls through to the dashboard automatically if:
--dashboardis passed explicitly;- stdin or stdout is not a TTY;
- no tmux server is running (so there's nothing to pick from);
- the listing is empty even counting discovered candidates.
The status column shows one of attached, live, configured,
discovered. Discovered entries come from discover: rules and are
materialised with tmux new-session on first attach.
The TUI refreshes pane_current_command for every pane every 2 s
(tunable via defaults.behaviour.auto_refresh_interval).
In the session tree — the session row appends unique non-idle
process names: claude vim. The window row shows the first non-shell
pane's process.
In the detail panel — for each window pane: a marker on the current preview pane, the index, the command, and the cwd.
Command drift: if config.yml declares command: nvim and the
live pane runs zsh, the detail panel prints
drift nvim ≠ expected: zsh
Example:
sessions:
work:
windows:
editor:
dir: src
command: nvim # expected processInline filter /: press / and type a fragment of a session /
window / process name. The footer counter shows 3/42. Enter keeps
the filter (navigation still works); Esc clears.
Marks are vim-style bookmarks so you can jump between frequently-used sessions or windows.
m<letter> set a mark on the current position
example: ma → mark 'a' on the focused window
Via palette: : → mark: set mark → enter a letter.
'<letter> jump to the mark and push the current position into the
last-location ring
example: 'a
'' return to the previous position (pop from the ring)
Every jump ('<letter>, attach, '') pushes the current position
into a 10-slot ring. Repeated '' cycles through it.
When the ring is non-empty, the footer shows '' ← prev.
Marks and the ring live in ~/.local/state/tmh/marks.json. Killing a
session/window/pane automatically invalidates marks that pointed at the
gone target.
Turn persistence off via defaults.marks.persist_across_sessions: false
or Settings → Marks.
For tmh to deliver a good UX (truecolor, fast escape, extended keys, inline status segment) the tmux server needs a minimal set of options. Check the current state and get a ready-to-paste snippet:
tmh tmux audit # ✓/⚠/✗ per option + hint on how to fix
tmh tmux audit --json # same, machine-readable
tmh tmux setup # snippet for ~/.tmux.conf (stdout)
tmh tmux setup --append # append a managed block to ~/.tmux.conf (idempotent)The audit covers:
- baseline (required for tmh to work well):
default-terminal tmux-256color+ RGB,mouse on,escape-time 0,extended-keys on; - recommended (UX niceties):
base-index 1,pane-base-index 1,renumber-windows on; - conflicts: hook
after-new-window(races with tmh's window-creation path),automatic-rename=on(clobbers window names); - integration: the
#(tmh status)segment instatus-right— without it, drift/reload badges don't show up in the status bar.
Recommended bind for ~/.tmux.conf:
bind R run-shell "tmh reload --all" # prefix R → dotfiles reload
set -ag status-right ' #(tmh status)' # drift/reload badgeson_create, on_attach, on_destroy are lists of shell commands run
at lifecycle events. Each runs under sh -c, inheriting env and
cwd from the resolved config.
Hooks live at three scopes:
| Scope | YAML path |
|---|---|
| Session | sessions.<name>.hooks.* / profiles.<name>.hooks.* |
| Window | sessions.<s>.windows.<w>.hooks.* |
| Pane | sessions.<s>.windows.<w>.panes[].hooks.* |
Profile hooks concatenate before session hooks at the session
scope; template hooks concatenate before window-specific ones when
a window extends: a template.
⚠ config.yml contains shell hooks:
sessions.epcp.on_attach: mise use
sessions.epcp.windows.db.on_create: docker compose up -d
Trust and run? [y/N]
After y, the file's SHA-256 is stored in ~/.local/state/tmh/state.db.
No more prompts until the file changes. Any edit re-triggers the
prompt.
For programmatic bypass (CI, audit) the internal
actions.HookOptions.NoHooks=true skips execution — exposed only via
code today, no CLI flag.
Snapshots — named restore points for the structure of every live session (windows + cwd + layout). Pane contents are not preserved — a hint records which process was running.
tmh snapshot save pre-demo
# ... break things ...
tmh snapshot restore pre-demoUndo — a short history of the last destructive action (currently
only kill_session). Before a kill, tmh stores a session snapshot in
the events table; tmh undo restores from that payload.
Export a sanitised YAML:
tmh export --minimal > team.yml--minimal does two things:
- redacts env keys ending in
_TOKEN,_KEY,_SECRET,_PASSWORD,_PWD,_API_KEY→<redacted>; - rewrites absolute
dir:values into{root, path}pairs when the prefix matches a declared root, removing user-specific absolute paths.
Your teammate:
go install github.com/mark1708/tmh/cmd/tmh@latest
tmh import team.yml --merge
tmh init--merge — overlay onto the existing config (incoming side wins on
conflicts). --replace — full replacement.
Declared sessions are the authoritative list — but enumerating every
project in a monorepo or scratch workspace gets tedious fast. The
discover: block auto-generates candidate sessions from the
filesystem (and optionally zoxide); they show up in tmh ls and the
picker, but aren't drift-checked.
discover:
- path: ~/work/orgA/services/* # tilde + filepath.Glob (no **)
template: go_service # seed each discovered session with this template
zoxide: true # additionally pull from `zoxide query --list`
zoxide_limit: 15 # cap the number of zoxide entries (default 20)Resolution order:
- Directories matching
path:glob (directories only; files and broken symlinks are skipped). - Top-N zoxide paths when
zoxide:is true and the binary exists. - Declared sessions always win — a session named in
sessions:suppresses the corresponding discovered candidate.
Discovered entries:
- appear in
tmh lswith adiscoveredstatus; - appear in the picker with their absolute directory;
- become real sessions via
tmux new-sessionon first attach; - are ignored by
tmh diff— they're candidates, not drift targets.
Without a discover: block nothing extra happens — everything in this
section is opt-in.
The authoring complement to tmh diff. When you build a layout by
hand (start a session, rename windows, arrange panes) and want to keep
it, tmh freeze writes it back into ~/.config/tmh/config.yml
without clobbering comments, templates, profiles, or existing
entries.
tmh freeze --dry-run # preview planned changes
tmh freeze # actually write
tmh freeze --session api # restrict to one sessionSemantics:
| Class of change | What freeze does |
|---|---|
| session not in config | add it with an inferred root |
| window not in config | add it (inferred root or absolute dir) |
| window matches config | mark as unchanged (no write) |
| window dir differs | report as conflict — do not overwrite |
Conflicts are left for you to resolve explicitly:
tmh sync --pull --all— destructive overwrite (config ← live);- manual edit in YAML;
- manual
tmuxrearrange so things match again.
Freeze and drift detection together turn into a closed loop: build
live → freeze → edit config → tmh diff shows you exactly what moved
since you froze.
tmh ships a JSON Schema document generated from config/types.go.
Every config.Write call prepends a modeline:
# yaml-language-server: $schema=https://raw.githubusercontent.com/mark1708/tmh/main/schemas/tmh.schema.json
version: 1
...If your editor runs yaml-language-server (default in VS Code's
YAML extension, Helix's yaml LSP, Neovim's lsp-zero /
nvim-lspconfig), autocompletion and inline validation light up
automatically — no manual configuration.
Regenerate the schema yourself when you fork or modify types:
make schema # fast — schema only
make docs # schema + man pages + completionsThe schema lives at schemas/tmh.schema.json and is committed so
release tarballs + Homebrew installs include it.
Writers that round-trip configs (tmh sync, tmh import) keep the
modeline on every edit. The auto-insertion can be opted out
per-writer via config.WriteOptions.NoSchemaHeader: true — useful
when writing machine-consumed YAML that shouldn't carry comments.
All tmh-written files are 0600:
~/.config/tmh/config.yml(may contain env secrets)~/.local/state/tmh/history.jsonl~/.local/state/tmh/state.db(trust hashes, marks, snapshots)~/.local/state/tmh/marks.json
Directories are 0755. Any existing file with wider perms gets
tightened on next write.
Shell commands inside config.yml never run unprompted. The first
time tmh sees hooks (or sees a config whose SHA-256 has changed) it
prints the full command inventory across every scope and asks for
explicit y. The decision is stored in the SQLite trust table keyed
by (path, sha256).
Lost trust decisions are not a security issue — they cause an extra prompt, nothing more. The table persists across upgrades.
tmh export --minimalredacts env keys matching*_{TOKEN,KEY,SECRET,PASSWORD,PWD,API_KEY}before printing YAML.env:values in config.yml are not encrypted at rest. tmh relies on file perms (0600) and your filesystem's access control; use secret managers + environment variable interpolation from the shell for anything sensitive.- Vulnerability disclosure policy — SECURITY.md.
tmh seems to hang after attach
prefix d inside tmux detaches and returns you to the TUI. If it's
genuinely stuck — Ctrl+\ (SIGQUIT) or pkill -INT tmh from another
terminal.
state.db is corrupt
internal/state exposes FixState(path) which renames the broken
file to state.db.broken.<ts> and starts fresh. No CLI wrapper yet —
do it by hand:
mv ~/.local/state/tmh/state.db ~/.local/state/tmh/state.db.broken.$(date +%s)Expected loss: snapshots / undo / trust decisions.
Ad-hoc session isn't flagged as drift
By design — sessions not in config are ignored. Add them via
tmh sync --pull (or tmh freeze).
Hooks don't run
If config.yml changed, the trust prompt re-fires. Either answer y
again or inspect ~/.local/state/tmh/state.db (table trust).
go install fails with 410 Gone / unknown revision
Ensure Go ≥ 1.25 and the full path
github.com/mark1708/tmh/cmd/tmh@latest. If the module is unreachable
via proxy.golang.org, set GOPROXY=direct.
Enable structured logging for debugging
TMH_LOG=debug tmhSupported levels: debug, info, warn, error. Logs go to
~/.local/state/tmh/tmh.log in JSON, rotated at 5 MB × 3 files.
Without TMH_LOG the log is entirely silent.
Drift/reload badge missing from tmux status-right
Run tmh tmux audit — likely the #(tmh status) segment is absent.
Fix with tmh tmux setup --append or add it by hand to
~/.tmux.conf.
Picker doesn't appear with bare tmh
Picker only activates with a TTY + a running tmux server. Otherwise
tmh routes to the dashboard (which can still bootstrap everything).
Force explicit: tmh --dashboard.
tmh reload --shell doesn't source my rc file
tmh picks the rc file by $SHELL — bash → ~/.bashrc, fish →
~/.config/fish/config.fish, zsh → ~/.zshrc, anything else →
~/.profile. Override with --rc <path>.
cmd/tmh/ cobra entry + subcommands
cmd/tmh-gen/ build-time generator: JSON schema, man pages, completions
internal/
config/ parser / resolver / validator / atomic writer, diff
(+ReasonCode), discover rules, JSON schema reflector
tmux/ Runner interface (CLIRunner) — the only tmux seam
tmux/tmuxtest/ MockRunner for tests (never imported by production code)
actions/ side-effect API; CLI + TUI are thin frontends
(includes AuditTmuxConfig, Setup, snapshots, hooks, freeze)
state/ SQLite WAL + busy_timeout: events / snapshots / trust /
reload_queue + JSONL history + marks + last-location ring
slogx/ global slog logger, rotating writer, TMH_LOG env
errors/ typed sentinels (en-only, stable API for errors.Is)
i18n/ go-i18n v2, embedded locales/{en,ru}.json, DetectLang
shell/ $SHELL → rc-file resolution (bash/zsh/fish/profile)
ui/ bubbletea: dashboard, picker, palette, settings, diff,
confirm, help, history, errrender
ui/pane/ Provider — pane_current_command cache (TTL, FindByCommand)
ui/refresh/ Refresher — periodic batch fetch with seq-based debounce
ui/toast/ Kind enum + TTL
ui/picker/ bare-tmh fuzzy picker (bubbles.list + textinput)
xdg/ XDG paths (Config, State, Backups, Log, History, Marks,
TmuxConf, Schemas)
Design rules:
- All side effects live in
internal/actions; CLI and TUI just call them. internal/tmux.Runneris the only contact withtmux. Tests usetmuxtest.MockRunner; nothing outsideinternal/tmuxforkstmuxdirectly.config.ymlmutations go throughconfig.PathSet/Delete/Rename+config.Writewith comments preserved viayaml.Node.- Errors are typed sentinels in
internal/errors— English-only and stable forerrors.Isand external tests. UI localisation happens at the boundary viainternal/ui/errrender. - JSON outputs are never localised:
Drift.Reason(en) +Drift.ReasonCode(stable) — the TUI resolves the code into localised text viai18n.T("drift.reason." + code).
Deeper notes — CONTRIBUTING.md + docs/architecture.md + docs/.
MIT — see LICENSE.