Work on multiple features in parallel with coding agents, pairing a jujutsu workspace with a tmux window per feature.
jumux add <feature> create a jj workspace + tmux window, start the agent
(-a/--agent overrides the agent; -t/--template applies a preset)
jumux remove [-f] [name] tear a feature down (defaults to the current one)
jumux merge [-f] [name] merge a feature into the base bookmark locally, then tear it down
jumux resurrect recreate tmux windows lost to a tmux crash/restart
jumux sync [name] re-apply configured files.copy/files.symlink to a workspace
jumux rebase [feature] rebase a feature's workspace onto its base revision
(--onto REV rebases onto REV instead)
jumux list show feature workspaces and their tmux windows
jumux pr [feature] push the feature's bookmark and open a GitHub PR
jumux mr [feature] push the feature's bookmark and open a GitLab MR
jumux sidebar toggle a live agent sidebar pane on every tmux window
jumux hook <status> record agent status (working|waiting|done) from hooks
jumux doctor check the environment and report problems to fix
jumux config show print the effective config and where each value came from
- Prerequisites: tmux, and a jujutsu repo
(ideally colocated with git —
jj git init --colocatein an existing git repo, orjj git initfor a new one). - Install jumux:
or, with Go:
brew install richardcase/tap/jumuxgo install github.com/richardcase/jumux@latest - Start tmux and run jumux inside the repo:
This creates a jj workspace, opens a tmux window for it, and starts the agent (see What
jumux add my-featureadddoes). The first run offers to install Claude Code and Codex hooks for agent status tracking. - Check on your features from any window:
jumux list jumux sidebar - Ship the work once the agent is done:
jumux pr my-feature # or: jumux mr my-feature - Clean up the workspace and tmux window:
jumux remove my-feature
Run inside tmux, anywhere in a jj repo (ideally colocated with git):
- Creates a jj workspace named
<feature>under$XDG_DATA_HOME/jumux/workspaces/<repo>/<feature>(falling back to~/.local/share/jumux/workspaces/<repo>/<feature>), based ontrunk(). - Opens a tmux window named
<feature>in that directory. The window's name is pinned (automatic-rename off) and tagged with the custom option@jumux-featureso jumux can find it again even if it gets renamed. - Types the configured agent command into the window and presses Enter, then switches to it. If the agent exits, the window's shell survives for inspection.
- Resolves the feature: the argument, else the workspace your cwd is in,
else the current window's
@jumux-featuretag.defaultis refused. - If the workspace's working-copy commit has changes, asks for confirmation
(
-f/-forceskips). Note thatjj workspace forgetnever deletes commits — described work stays in the repo either way. - Forgets the jj workspace, deletes the directory, and kills the tmux
window — in that order, so running
removefrom inside the feature's own window still completes the cleanup (the final message disappears with the window).
Cleanup is idempotent: stale state (directory deleted by hand, window closed, etc.) is skipped, and it only errors if nothing at all was found.
jumux remove --all-done batch-removes every feature whose most recently
recorded agent status (see Agent status icons) is
done, running the same steps above for each one. If the feature you are
currently in is among them, it is removed last.
For merging a feature in locally instead of going through a PR/MR:
- Resolves the feature the same way
removedoes, including the dirty-working-copy confirmation. - Rebases the feature onto
base_bookmark(defaultmain, configurable — see Configuration) and movesbase_bookmarkto the rebased tip. This is local only — nothing is pushed. - If the rebase leaves the feature conflicted, stops here: the base
bookmark is left untouched and the workspace/directory/window survive so
you can resolve the conflict and run
mergeagain. - Otherwise, cleans up exactly like
remove: forgets the jj workspace, deletes the directory, and kills the tmux window.
Toggles a live agent sidebar, modeled on
workmux's sidebar:
a narrow pane on the left edge of every window of every tmux session.
Each row is a window tagged with @jumux-feature (any session, any repo),
showing an agent status icon, repo/feature, a right-aligned jj working-copy
icon, and a ! marker when tmux has flagged activity in that window.
| Column | Icon | Meaning |
|---|---|---|
| agent | ⠋ animated spinner (cyan) |
agent is working |
| agent | ? (yellow) |
agent is waiting for input |
| agent | ✓ (green) |
agent is done |
| agent | · (dim) |
no agent status recorded |
| jj | ✓ (green) |
working copy clean |
| jj | ● (yellow) |
working copy has changes |
| jj | ? (dim) |
jj state unknown |
| stale | z (dim) |
idle beyond stale_after_hours (no jj changes or hook updates) |
When windows from more than one jumux-managed repo are open, rows are
grouped under a header naming each repo; with only one repo open the
header is omitted. jumux list likewise adds a REPO column when it
detects a jumux-tagged tmux window belonging to a different repo.
Keys: j/k (or arrows) move, g/G jump to first/last, Enter switches
to the selected feature's window (across sessions), q closes the sidebar
everywhere — same as running jumux sidebar again.
add splits a sidebar pane into its new window automatically while the
sidebar is open; windows created by plain tmux pick one up on the next
toggle. Panes only poll jj while their window is visible, so idle windows
cost nothing.
The agent column is fed by Claude Code
hooks calling
jumux hook <status>. The first jumux add offers to install the
hooks into ~/.claude/settings.json; to set them up by hand, add:
{
"hooks": {
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "jumux hook working" }] }],
"PostToolUse": [{ "hooks": [{ "type": "command", "command": "jumux hook working" }] }],
"Notification": [{ "hooks": [{ "type": "command", "command": "jumux hook waiting" }] }],
"Stop": [{ "hooks": [{ "type": "command", "command": "jumux hook done" }] }]
}
}(PostToolUse flips the status back to working after a permission approval;
without it a row would stay "waiting" until the turn ends.)
The same command also works with Codex CLI
hooks. The first jumux add
offers to install them into ~/.codex/hooks.json; to set them up by hand,
add:
{
"hooks": {
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "jumux hook working" }] }],
"PostToolUse": [{ "hooks": [{ "type": "command", "command": "jumux hook working" }] }],
"PermissionRequest": [{ "hooks": [{ "type": "command", "command": "jumux hook blocked" }] }],
"Stop": [{ "hooks": [{ "type": "command", "command": "jumux hook done" }] }]
}
}Codex has no hook event equivalent to Claude Code's idle notification or a
tool-use-failure event, so status never reaches waiting or error for a
Codex-driven feature.
jumux hook resolves the calling pane's window via $TMUX_PANE and
writes a small state file under ~/.local/state/jumux/status/ (or
$XDG_STATE_HOME/jumux/status/). Outside tmux or outside an jumux
feature window it does nothing, so the hooks are safe to enable globally.
The sidebar prunes state files for closed windows, remove deletes the
feature's file, and a working entry with no update for 15 minutes is
treated as unknown.
Global ~/.config/jumux/config.toml, overridden per-key by
.jumux.toml at the main repo root:
agent = "claude" # command to run; "{feature}" is substituted if present
select_window = true # switch to the new window after add
base_revision = "trunk()" # revset new workspaces are based on
base_bookmark = "main" # bookmark `jumux merge` rebases onto and moves to the merged tip
window_prefix = "" # prepended to tmux window names
sidebar_width = 32 # sidebar pane width in columns
sidebar_refresh = 2 # sidebar refresh interval in seconds
notify = true # send a desktop notification on status changes
stale_after_hours = 168 # idle threshold for the stale indicator; 0 disables it
notify_quiet_start = "" # start of a daily "HH:MM" window to suppress notifications
notify_quiet_end = "" # end of that window; leave both unset to disable quiet hours
notify_webhook = "" # if set, also POST {"title","message"} JSON here on status changes
post_create_hooks = [] # shell commands run (in order) after the workspace/window are created, before the agent starts
pre_remove_hooks = [] # shell commands run (in order) before workspace removal; a failure aborts removal
hook_timeout_seconds = 300 # max seconds a single hook command may run; 0 disables the timeoutRun jumux config show to see the effective merged value of every key,
along with whether it came from the repo file, the global file, or the
built-in default — handy for debugging precedence between the two files.
notify gates the OS desktop notification (osascript on macOS,
notify-send on Linux) sent by jumux hook on a status change to
waiting/done/blocked/error.
notify_quiet_start/notify_quiet_end bound a daily local-time window
(24h "HH:MM") during which notifications (both the desktop notification
and the webhook) are suppressed — handy for muting notifications
overnight. A window where start is after end wraps past midnight, e.g.
notify_quiet_start = "22:00", notify_quiet_end = "06:00". Leave either
unset to disable quiet hours.
notify_webhook, if set, additionally POSTs a JSON body
({"title": "...", "message": "..."}) to that URL on the same status
changes — useful for routing notifications somewhere other than the local
desktop (chat webhook, phone push gateway, etc.) when you're away from
the machine. A webhook failure is logged but never fails the hook.
post_create_hooks/pre_remove_hooks run each command in order via sh -c
in the workspace directory, with output streamed live to your terminal and
JUMUX_EVENT/JUMUX_FEATURE/JUMUX_WORKSPACE_PATH/JUMUX_REPO_ROOT/
JUMUX_WINDOW_NAME set in the environment. A failing (or timed-out) command
stops the remaining commands and aborts the operation: add rolls back the
workspace and tmux window it just created, and remove never touches the
jj workspace or the directory. pre_remove_hooks always run, even with
-f/--force (force only skips the interactive dirty-workspace prompt).
pre_remove_hooks only run when the workspace directory still exists, so
cleaning up a partial state (e.g. a stale jj workspace entry or tmux window
whose directory is already gone) is never blocked by a hook.
hook_timeout_seconds bounds each command individually; set it to 0 to
disable the timeout. Note: only the immediate sh process is killed on
timeout — a hook that backgrounds work or spawns children may leave those
running.
Example with a starting prompt:
agent = "claude 'Work on the {feature} feature'"Note on base_revision: jj's builtin trunk() resolves via remote
bookmarks (main@origin etc.), which can fail to resolve in a local-only
repo with no remote. If the default base_revision fails and you haven't
overridden it, jumux add automatically retries with @- and prints a
warning; set base_revision = "main" (or similar) explicitly to avoid the
fallback and its warning.
Note on colocation: depending on your jj version, secondary workspaces may
not contain a .git directory even when the main repo is colocated, so
agents should use jj commands inside the workspace.
Named templates bundle a base_revision/agent/window-option preset for a
recurring kind of feature, selectable with jumux add --template <name>
(-t for short):
[templates.bugfix]
base_revision = "main"
agent = "claude 'fix the {feature} bug'"
window_prefix = "bug-"Only the fields set in the template override the base config; anything
left unset falls through to the regular agent/base_revision/etc.
values. -a/--agent still wins over a template's agent if both are
given. A template defined in .jumux.toml fully replaces a global
template of the same name (its fields are not merged individually).
post_create_hooks follows the same rule: a template that sets it replaces
the base post_create_hooks list entirely (not merged/appended). There is
no per-template override for pre_remove_hooks, since removal has no
reliable way to know which template a workspace was originally created
with — pre_remove_hooks always comes from the regular (non-template)
config.
[files] lists glob patterns (relative to the main repo root) to copy or
symlink into a new workspace on jumux add, and to re-apply to an existing
workspace with jumux sync [name] — handy for gitignored files a workspace
needs to actually build/run (.env, node_modules, a virtualenv, build
caches):
[files]
copy = [".env", "config/local.*"]
symlink = ["node_modules", ".venv"]Patterns are resolved with filepath.Glob (so */?/[...] work, but not
**); a pattern matching nothing is skipped silently, since these files are
often optional. A destination that already exists is left untouched and
reported as a warning rather than overwritten, so re-running jumux sync is
safe. A copy/symlink failure only prints a warning — it never blocks jumux add from finishing.
brew install richardcase/tap/jumux
or, with Go:
go install github.com/richardcase/jumux@latest
The binary is named jumux. If you'd rather type jjm, add an alias:
alias jjm=jumux
This repo uses mise to pin the Go, golangci-lint,
and goreleaser versions used to build, test, and release it (see
mise.toml). After installing mise, run:
mise install
make build, make test, make lint, and make vet all run through
mise exec, so they automatically use the pinned tool versions.
Contributions are welcome! See CONTRIBUTING.md for how to build, test, and submit changes, and AGENTS.md for project layout and conventions. Please note this project follows a Code of Conduct.
jumux is licensed under the Apache License 2.0.
jumux was inspired by workmux.