wsx (WorkspaceX)
Terminal UI for managing Claude Code, Pi, Hermes, Codex, or oh-my-pi sessions in git worktrees.
wsx gives each piece of work its own isolated git worktree and coding-agent session, and a dashboard to launch, monitor, and orchestrate many of them in parallel — including multiple agents collaborating in a single workspace.
- New here? Start with the Quick start.
- Browsing what it does? See Key features.
- Looking for a command or setting? Jump to the CLI reference or Reference.
Demo videos of parallel and multi-agent sessions are on the project README.
This section covers what wsx is, how to install it, and how to wire it up to your tools.
- Parallel agent sessions in git worktrees: every workspace is its own branch + worktree; switch with one key.
- Multiple coding agents: run Claude, Pi, Hermes, Codex, or oh-my-pi (
omp) per workspace. Set a global default withcoding_agentor override per workspace with--agent. See Coding agents. - Multi-agent workspaces: attach several agents to one worktree, switch focus with a keypress, and have them message each other. See Multi-agent workspaces.
- Cross-session attention alerts: terminal bell +
!or?marker when a session is awaiting permission, has gone idle or has a question. - Activity sub-line per workspace: see the latest tool call or message from each session at a glance.
- Configurable Workspace Detail Bar: Display up to four independent containers with built-in or custom modules. See Workspace detail bar.
- Project manager digest: an instant, native pane summarizing what every workspace is for, where it's at, and what's next — no agent session required.
- Remote control: attach from claude.ai/code or the mobile app; or run wsx in tmux+ssh for full-fidelity desktop access; store and access remote connection commands via the
remoteCLI. - Pinned commands: define your
/pull-request,/feedback,/ultrareviewshortcuts once; fire them withCtrl-x <digit>or a click while attached or from the workspace details bar. - Bundled agent skills:
wsx(drive the CLI),agent-review(spawn a peer reviewer), andhandoff(continue a finished workspace's epic in a fresh one, briefed with a summary of the old session). Installed withwsx setup install-skill; pin them as chips. See Agent skill. - Prompt tags: wrap a body in an XML tag and insert it unsubmitted with one keystroke. See Prompt tags.
- Related repos: declare related wsx repos per primary repo; workspaces spawn with
--add-dirfor each and a read-only system prompt so claude can read but won't edit them. Agent is provided with the wsx skill to use the CLI to orchestrate between repos. - Keyboard first navigation: comprehensive keybindings for every action, from workspace creation to process killing to digest refreshes.
- Frictionless workflow: auto-rename branches from your first prompt, per-repo setup/archive scripts, editor/terminal/diff hooks.
Installation
Every method gives you the same wsx binary. Pick one.
wsx needs git on your PATH. Install the
GitHub CLI as well if you want pull request numbers
and review marks on the dashboard.
Homebrew
macOS and Linux, on both Intel and ARM:
brew tap bakedbean/workspacex https://github.com/bakedbean/workspacex
brew install bakedbean/workspacex/wsx
Two details, both deliberate:
- The tap URL is explicit because the formula lives in the main repository
rather than in a separate
homebrew-repository. - The install name is fully qualified. Homebrew 6 refuses to load a formula
from a tap you have not trusted, and installing by the full name trusts it
for you. A bare
brew install wsxstops with a trust error instead.
To upgrade:
brew update && brew upgrade bakedbean/workspacex/wsx
cargo-binstall
cargo-binstall downloads the same prebuilt binary that Homebrew uses, without a Rust build:
cargo binstall --git https://github.com/bakedbean/workspacex wsx
The --git flag is necessary because wsx is not published on crates.io yet.
Nix
The repository is a flake. Run wsx once without installing it:
nix run github:bakedbean/workspacex
Install it into your profile:
nix profile add github:bakedbean/workspacex
Nix older than 2.34 calls that subcommand install instead of add.
Add it to a NixOS or home-manager configuration:
{
inputs.wsx.url = "github:bakedbean/workspacex";
# Then, in your package list:
# inputs.wsx.packages.${pkgs.stdenv.hostPlatform.system}.default
}
nix develop gives you a shell with Rust, git, and gh ready for work on
wsx itself. Note that it uses the Rust version in nixpkgs, not the version
pinned in rust-toolchain.toml, so run cargo fmt through rustup if you
need to match CI exactly.
Prebuilt binaries
Download a tarball from the releases page, then:
tar xzf wsx-<version>-<target>.tar.gz
sudo install -m 755 wsx-<version>-<target>/wsx /usr/local/bin/wsx
Each tarball ships with a .sha256 file. Check it before you install:
f=wsx-<version>-<target>.tar.gz
echo "$(cat "$f.sha256") $f" | shasum -a 256 -c -
The releases cover these targets:
| Platform | Target |
|---|---|
| macOS, Apple silicon | aarch64-apple-darwin |
| macOS, Intel | x86_64-apple-darwin |
| Linux, ARM64 | aarch64-unknown-linux-gnu |
| Linux, x86-64 | x86_64-unknown-linux-gnu |
The Linux binaries are built against glibc 2.35.
From source
You need Rust 1.85 or later, because wsx uses edition 2024.
git clone https://github.com/bakedbean/workspacex
cd workspacex
cargo build --release
./target/release/wsx
cargo install --path . puts the binary on your PATH.
Install wsx first. See Installation for every method; the short version on macOS and Linux is:
brew tap bakedbean/workspacex https://github.com/bakedbean/workspacex
brew install bakedbean/workspacex/wsx
Then point wsx at a repository and launch it:
wsx repo add /path/to/your/repo
wsx # launch TUI
Press n (or SHIFT + N for permissive) to create your first workspace, then enter to attach. Claude Code spawns inside the worktree.
Out of the box, wsx drives your agent sessions, but the three keys that make it
feel like a cockpit ([e] editor, [v] diff, [t] terminal) do nothing useful
until you tell wsx which tools to launch. These aren't configured by default,
and the payoff isn't obvious until you've set them: jump straight from a
workspace into your editor, a full branch diff, or a fresh shell, all rooted in
that workspace's worktree.
Set them once globally with wsx config set. Sample commands for a Neovim +
Alacritty setup:
# [e] — open the worktree in Neovim, running inside a new Alacritty window
wsx config set editor_cmd "alacritty --working-directory={path} -e nvim"
# [v] — view the branch diff in Neovim via diffview.nvim
wsx config set diff_cmd "alacritty --working-directory={path} -e nvim -c 'DiffviewOpen {base}...HEAD'"
# [t] — open a shell in the worktree in a new Alacritty window
wsx config set terminal_cmd "alacritty --working-directory={path}"
A few things worth knowing, all covered in detail under Editor, terminal, and diff integration:
{path}and{base}placeholders.{path}expands to the worktree path and{base}to the diff base ref (e.g.origin/main). If a command has no{path}, wsx appends the worktree path as a trailing argument. That's why Alacritty uses--working-directory={path}— passing the path positionally (alacritty /some/path) is an error, and setting its working directory also lets Neovim start in the worktree without opening the directory as a buffer.- TUI editors need a terminal wrapper. vim/nvim/helix are launched detached
from wsx and have no TTY of their own, so wrap them in a terminal command
(
alacritty -e nvim). GUI editors (code,cursor,zed) work directly. - Why
{base}...HEAD(three dots). Three dots anchor the diff at the merge base, so a stale localmaindoesn't pollute the view — the same diffgh prshows.
editor_cmd and terminal_cmd fall back to $VISUAL/$EDITOR and $TERMINAL
respectively if unset; diff_cmd has no fallback and must be set explicitly.
Each can also be overridden per-repo — see the linked section.
Day-to-day driving of the wsx dashboard: keys, panes, indicators, and the detail bar.
Dashboard
| Key | Action |
|---|---|
Up / Down (or k / j) | Move selection through repo headers and workspaces |
h / l | Fold / unfold the focused repo (idempotent; use zz to toggle) |
enter (or i) on a workspace | Attach to its claude session (spawns or resumes) |
enter (or i) on a repo header | Open the New Workspace modal targeting that repo |
n | New workspace in the selected row's repo |
Shift + N | New workspace in permissive mode (claude launches with --dangerously-skip-permissions) |
e | Open the selected workspace in your editor (no-op on repo header) |
t | Open the selected workspace in a terminal (no-op on repo header) |
v | View diff of the selected workspace's branch vs the repo's base branch (auto-detected; no-op on repo header) |
Shift + K | On a workspace: show processes under its worktree. On a repo header: move the repo up one slot (persisted) |
Shift + J | On a repo header: move the repo down one slot (persisted). No-op on a workspace |
s | Open repo settings modal for the selected repo (or the parent repo when a workspace is selected) |
d | Archive the selected workspace (no-op on repo header) |
o | Cycle how workspaces are ordered inside a repo: by recency (default) or by status. Persisted. |
q | Quit (kills all running sessions) |
p | Toggle the project-manager digest pane (opens focused, instant, no agent session) |
Tab | Swap focus between dashboard and the digest pane (when visible) |
z z | Toggle fold on the focused repo |
z a | Expand all repos (override default-fold heuristic) |
z M | Fold all repos |
j / k (or arrows) (when digest focused) | Move selection through digest cards |
Enter (when digest focused) | Attach to the selected workspace |
q / p (when digest focused) | Close the digest (q only closes it while the digest is focused — dashboard-focused q quits wsx) |
r (when digest visible) | Force a git/PR cache refresh |
New Workspace / Confirm Archive modals
| Key | Action |
|---|---|
enter | Confirm |
esc | Cancel |
y / n | Confirm/cancel on ConfirmArchive |
Printable chars / backspace | Edit the name field on NewWorkspace |
Workspace actions card (? on a workspace)
| Key | Action |
|---|---|
r | Rename the workspace (and its git branch) |
C | Pick a name color for its dashboard row |
o | Open its setup log — see below |
x | Cancel an in-flight setup (creates only; archive is not cancellable) |
? / esc | Close the card |
Other keys (e, t, v, g, c, enter) are forwarded to the dashboard and act on the selected workspace.
Setup log viewer (o)
Whenever the selected workspace carries a lifecycle badge — ⚙! (setup failed), ⚙? (setup cancelled), or a spinner (being created or archived) — the footer shows a ? o setup log hint, since the badge itself has no room to say where the reason lives.
The viewer shows the setup script's output for the selected workspace, whatever state it is in: the live tail while the workspace is still being created, and the persisted log from ~/.local/state/wsx/logs/ once it has finished. Stderr lines are marked ! and highlighted in both. Only the last 256 KiB of a log is read, and at most 2000 lines are shown, so a very verbose setup script loses the start of its output — the end, which says how the run finished, is always kept.
A workspace with no log to show says which case it is rather than showing an empty pane: no setup script was ever run, the file could not be read (with the path and the error), or — while archiving — that archive output is not kept at all.
| Key | Action |
|---|---|
Up / Down (k/j) | Scroll one line |
PageUp / PageDown | Scroll ten lines |
g / Home | Jump to the start of the log |
G / End | Jump back to the end |
esc / enter | Close (background work keeps running) |
Attached workspace
Keystrokes are forwarded to the running claude session, except:
| Key | Action |
|---|---|
Ctrl-x d | Close the focused pane. When only one pane is open, detaches back to the dashboard (session keeps running). |
Ctrl-x Shift-D | Save the current split layout for this workspace, then detach to the dashboard. Restored on next attach. |
Ctrl-x Esc | Dismiss the navigation overlay without detaching (stay in the attached view). |
Ctrl-x ←/→/↑/↓ | Move focus between split panes in that direction (vim's Ctrl-w motions). |
Ctrl-x u | Open the floating updates panel (a stripped-down dashboard in the dashboard's order; v/s open in a split, o / G cycle the dashboard's sort and grouping, / filters the list) |
Ctrl-x a | Open the agents panel to add/remove agents in this workspace (see Multi-agent workspaces) |
Ctrl-x e | Open the attached workspace in your editor (same editor_cmd as [e] on the dashboard) |
Ctrl-x t | Open the attached workspace in a terminal (same terminal_cmd as [t]) |
Ctrl-x v | View diff of the attached workspace's branch vs the base branch (same diff_cmd as [v]) |
Ctrl-x k | Show processes running under the attached workspace's worktree |
Ctrl-x < | Open the prompt-tag picker (wrap a body in an XML tag and insert it unsubmitted) |
Ctrl-x x | Send a literal Ctrl-x to claude |
Ctrl-x c | Toggle the change chronology bar on/off |
Ctrl-x C | Swap the chronology bar's side (left ↔ right) |
Ctrl-x → (bar on right) / Ctrl-x ← (bar on left) | Move keyboard focus into the chronology bar (from the adjacent edge pane only) |
Ctrl-x ← (bar on right) / Ctrl-x → (bar on left) | Return focus from the bar to the agent pane |
↑ / k (bar focused) | Move selection up (toward newer entries) |
↓ / j (bar focused) | Move selection down (toward older entries) |
g (bar focused) | Jump to the top (newest entry) |
G (bar focused) | Jump to the bottom (oldest entry) |
Enter (bar focused) | Open the full-change detail modal for the selected entry |
Esc (bar focused) | Return focus to the agent pane |
When a workspace has more than one agent, the footer also binds bare keys q w r y i o p s h j (no leader) to switch the focused pane between agents — see Multi-agent workspaces.
If pinned_commands is configured (globally or per-repo), a one-row chip strip appears between the claude pane and the footer. Each chip shows [N] Label:
[1] PR [2] FB [3] /loop /baby… [4] UR
Fire a chip with Ctrl-x <digit> (1-9) or by clicking on it. By default the chip's command + \r is written to claude exactly as if you'd typed and submitted it (see below for chips that only type).
Configure via the standard config CLI:
wsx config edit pinned_commands # opens $EDITOR on the current value
wsx config set pinned_commands @./pinned.txt # load from a file
wsx config set pinned_commands "" # clear
One entry per line:
PR=/pull-request
FB=/feedback
/loop /babysit-prs
UR=/ultrareview
Label=command shows the label as the chip; a bare line uses the command itself. Labels are truncated past 14 columns. Both sides of = are trimmed.
At narrow terminal widths trailing chips drop from view; their keyboard shortcuts still work.
Chips submit by default. To leave a command typed but unsubmitted — for one that takes an argument you want to choose each time — end it with ... (or …):
review=/agent-review ...
Firing that chip while attached writes /agent-review (the marker is stripped; the space before it is kept) and leaves the cursor there, so you type the reviewer kind and press enter. Fired from the dashboard, where the agent's prompt isn't visible, the text is staged in the detail bar's reply input instead — finish the line there and press enter to send it. The ... / … suffix is reserved: a command that genuinely ends in one can't be pinned as a submitting chip. Put the marker in the label too (review…=/agent-review ...) if you want the chip itself to show it won't submit; a bare /agent-review ... line does that automatically.
A command that needs input can also ask for it instead: the bundled handoff skill asks "what should the new workspace implement?" when fired with no argument, and agent-review asks which reviewer kind to spawn.
Prompt tags
Anthropic's prompting guidance recommends separating the parts of a prompt
with XML tags — <context>…</context>, <task>…</task>,
<constraints>…</constraints> — so the model can tell them apart. Prompt
tags make that one keystroke from the attached view.
Press Ctrl-x < (or click the <> chip in the chat footer) to open the
picker:
name: ▏
1 context ×12
2 task ×7
3 constraints ×2
[↑/↓] move [enter] body [1-9] pick [^d] delete [esc] close
- Typing filters the list by prefix.
Enteron a listed tag opens its body box;Enteron a name that isn't listed creates it. 1–9jump straight to a listed tag while the name field is empty.Ctrl-ddeletes the selected tag.
The body box is a small multi-line editor: Enter inserts a newline,
arrows/Home/End move, Esc goes back to the picker (keeping your draft).
Tabs are kept as tabs (shown four columns wide). Pasted CRLF line endings
become single newlines; control characters other than newline and tab are
stripped when inserting.
Ctrl-s inserts
<context>
…your text…
</context>
into the agent's composer without submitting, so you can stack several
tagged sections and add a plain instruction before pressing Enter yourself.
Each insert bumps the tag's use count; the three most-used tags sit in the
footer as <context>-style chips — click one to go straight to its body box.
Tag names are ASCII-only: they must start with a letter or _ and contain
only letters, digits, _, . and - ([A-Za-z_][A-Za-z0-9_.-]*).
If the insert can't be confirmed — no agent in the focused pane, or the agent has exited or stopped responding — the body box stays open with your draft and a one-line notice, so nothing you typed is lost.
The list lives in the prompt_tags setting, one name=uses per line:
wsx config get prompt_tags
wsx config edit prompt_tags # opens $EDITOR on the current value
wsx config set prompt_tags "context=12
task=7"
wsx config set prompt_tags "" # clear
Lines that don't parse (an invalid name, a non-numeric count) are dropped when the list is read and disappear on the next save.
The footer chips are the $tags bar segment — see
Themes to move, restyle, or drop them. The
Ctrl-x < chord works even when a theme omits the segment.
If you have your own ~/.config/wsx/theme.toml with an explicit
[attached_bottom].format, the chips only appear once you add ($tags )
beside $pins in that format and give the segment a [tags] table:
[attached_bottom]
format = "$keys ($pins )($tags )"
[tags]
format = "[<$label>]()"
separator = " "
more_format = "[ <> ](bold)"
The bundled examples in docs/examples/theme-*.toml carry styled versions
of both.
wsx enables terminal mouse capture so the trackpad / wheel scrolls through the session's history (instead of getting translated into arrow keys that claude reads as prompt-history navigation). One consequence: native click-and-drag selection no longer works by default.
To select text from the claude pane, hold Shift while dragging — most modern terminals (Alacritty, Kitty, WezTerm, iTerm2, GNOME Terminal) bypass mouse capture under Shift and fall back to OS-native selection. iTerm2 also supports right-click → "Bypass mouse reporting", and macOS terminals often accept Option as the modifier instead of Shift.
Clickable dashboard targets
Mouse capture also makes parts of the dashboard clickable:
- A workspace row's PR chip (
⏺ #123 open) opens that pull request in your browser. - A repo header's PR link opens that repo's pull requests
filtered to your own open ones — the GitHub PRs tab with
is:pr is:open author:@mealready applied.
The repo PR link sits just before the repo's path, normally in the same
dim colour, so the two read as one cluster identifying the repo. It turns
green — the same green a row's open-PR chip uses — when at least one
of that repo's workspaces has a pull request GitHub still counts as open,
so the colour tells you whether the link leads anywhere before you click
it. Drafts and conflicted PRs count, since the link's
is:pr is:open author:@me query lists them too; merged and closed ones
have dropped out of that list and leave the link dim. Folding a repo
hides its rows but not this signal.
With nerd_fonts off the link
renders as the literal text PR:
▾ ─── wsx PR /home/eben/workspace/wsx ────────────── ? 1 ! 1 4 ws
▲ click here
With nerd_fonts on it renders instead as the git-pull-request octicon
(nf-oct-git_pull_request) at U+F407 — the same glyph a workspace row
uses for an open PR. That codepoint lives in the Private Use Area, so it
only appears if your terminal font actually patches it; if you see a blank
or a tofu box in that position, the link is still there and still
clickable.
Repos without a link keep those columns blank rather than closing the gap, so every path starts in the same column either way.
It appears only on repos whose origin remote points at github.com,
so a repo wsx can't build that view for shows nothing rather than a
link that opens a dead tab. Self-hosted GitHub Enterprise remotes are
not recognised. Both actions shell out to
gh, which must be installed and
authenticated.
| Symbol | Meaning |
|---|---|
● | Session is running in this wsx process |
↻ | Resumable — a prior claude session exists for this worktree; attach to continue |
○ | No session ever started here |
✕ | Workspace state is Failed (worktree creation didn't succeed) |
[setup-failed] badge | Setup script exited non-zero; workspace is otherwise usable (? then o shows the log) |
Activity column for running sessions:
active— output within the last 2 secondsidle— output within the last 30 secondswaiting— no output for over 30 secondsoff— no current sessionresumable— prior session exists, not currently running
Multiple agents
A non-primary agent's colored cell in the dashboard's agent strip becomes a
spinner when its running session has produced terminal output within the last
2 seconds. It returns to a bar when quiet; exited peers leave the strip.
If peers overflow the strip, the + marker animates while any hidden peer is active.
This is terminal activity, not task status: typing or a resize repaint can
briefly animate a peer cell too. The workspace's task-status glyph, section,
age, details, and attention alerts are unchanged. Peer output does not move a
completed workspace into WORKING, and a primary question remains visible
alongside the peer indicator.
Activity sub-line
Below each workspace row, wsx shows the most recent event from claude's
session log (tailed from ~/.claude/projects/<encoded-cwd>/):
● fix-bug bakedbean/fix-bug ~3 ?1 active
└ ran `cargo test --workspace` (3s ago)
The sub-line updates on the 2-second poll tick. Workspaces with no claude session yet show no sub-line. Recognized events:
- User message →
user: <text> - Assistant text →
<text> - Assistant tool use (Bash) →
ran `<command>` - Assistant tool use (other) →
using <ToolName>
Lines longer than ~70 characters are truncated with an ellipsis.
Diff counts column
Compact summary of git status per workspace, refreshed every 2 seconds:
| Symbol (plain) | Symbol (nerd) | Meaning |
|---|---|---|
~N | N | Modified/staged/added/deleted tracked files |
?N | N | Untracked files |
↑N | N | Commits ahead of upstream |
↓N | N | Commits behind upstream |
Zero values omitted. Clean workspaces show nothing in this column.
Attention alerts
wsx watches each workspace for two distinct "user needs to act" signals:
- A
tool_useevent in the session log has been pending for ≥3 seconds — almost always means claude is showing a permission prompt for a tool. In this case the activity column readsawaitingand the sub-line shows└ ⚠ awaiting permission: <tool> (<age>). - The claude session has gone ≥30 seconds without producing PTY output
(state flips from
activeoridletowaiting).
On either transition wsx considers the workspace to need attention:
- A terminal bell (
\x07) is written to stdout. Your terminal config decides whether to beep, flash, or ignore. wsx also clears the terminal's bell urgency hint (private mode1042) while it runs, so on terminals that honour it (Alacritty, xterm) the bell does not ask the window manager for attention. That matters on Hyprland withmisc:focus_on_activate = true(Omarchy's default), where an urgency request switches the OS workspace to the terminal. On exit wsx puts the mode back only if the terminal reported it on at startup, so xterm (off by default) and a session that turned it off stay that way. Terminals without mode 1042 (kitty, foot, ghostty) keep their own bell-urgency setting; the sequence also does not cross a tmux boundary. - A
!marker appears at the start of the workspace's row on the dashboard.
The marker clears the moment you attach to the workspace (Enter on the row).
The first observation of any workspace establishes a baseline; no bell rings
for workspaces that are already in waiting or awaiting state when wsx
launches.
Turn off both via wsx config set notifications off.
[k] on the dashboard (or Ctrl-x k while attached) shows long-running
processes whose current working directory is inside the selected
workspace's worktree — dev servers, watchers, anything you started in
that worktree from a terminal. Workspaces with detected processes show
a ~N count between the branch and activity columns on the dashboard.
The modal lists each process's PID, command, and full cwd:
─── Processes — fix-bug ──────
PID COMMAND CWD
12345 npm /home/user/wt/fix-bug
12389 pytest /home/user/wt/fix-bug/tests
─────────────────────────────
[↑/↓] move [r] run [k] term [K] kill [esc] close
k sends SIGTERM to the highlighted process; K sends SIGKILL.
After either, wsx immediately re-scans so the list reflects the new
state.
r opens a prompt to run a command in the selected workspace's worktree —
handy for starting a dev server without opening a separate terminal. The
command runs via sh -c as a background process, with stdout and stderr
captured to a log file under ~/.local/state/wsx/logs/; the path is shown
after launch. It runs detached (its own session, reparented away from wsx),
so it keeps running if you close the dashboard and survives until it exits or
you stop it. Because it runs in the worktree, it appears in this same list on
the next scan, where K stops it.
Notes:
- Detection runs once every 10 seconds in the background via
lsof -d cwd. - Shells and editors (bash, zsh, nvim, code, etc.) are filtered out so the count surfaces what's interesting — your dev server, not the terminal hosting it.
- Helper processes spawned by Claude Code and editors (MCP servers,
language servers) are hidden too, since they inherit the worktree cwd
but aren't work you launched. The exception is a process holding a
listening TCP socket: a dev server started from inside Claude Code (e.g.
a
pnpm devon:3000) still shows up and can be killed here, while the stdio-only helpers stay filtered. - wsx never starts these processes itself. Launch them however you
like (the
[t]terminal keybind is one option). The feature is observability plus a kill hook, not lifecycle management. - The one exception is archive: archiving a workspace stops every
process this list would show for it before the archive script runs and
the worktree is removed. Each gets
SIGTERM, then two seconds to exit, thenSIGKILLif it is still running. Both the dashboard'sdandwsx workspace archivedo this.--keep-worktreeskips it: keeping the checkout means keeping whatever is running in it. The teardown is best-effort — a process wsx cannot signal (for example one owned by another user) is logged and shown in the archive progress, and the archive continues. Processes started after the scan, such as by the archive script itself, are not covered. - Requires
lsofto be installed (standard on most Linux/macOS setups). If it's missing, the count stays at 0 and the modal shows "(no tracked processes)" — no errors.
When a workspace is selected on the dashboard, wsx renders a multi-column
detail bar across the bottom. The body is divided into 1–4 equal-width
containers; each container holds one or more modules stacked
vertically. Four built-in modules ship today: session_summary,
recent_chat, processes, recent_files. The bar's appearance is
controlled by the detail_bar_config setting — globally via wsx config,
with optional per-repo overrides.
Schema and defaults
The global value is a full DetailBarConfig JSON blob. Every field is
optional; missing fields fall back to defaults. Out-of-range values are
clamped on save (see below).
{
"visible": true,
"height": {
"percent": 30,
"min_rows": 8,
"max_rows": 18
},
"containers": [
["session_summary"],
["recent_chat"],
["processes", "recent_files"]
]
}
| Field | Type | Default | Effect |
|---|---|---|---|
visible | bool | true | Master toggle. When false, the bar is hidden entirely and Tab skips the reply input. |
height.percent | u8 | 30 | Target height as a percent of the terminal's rows. Clamped to [5, 80]. |
height.min_rows | u16 | 8 | Floor on the bar's height. Clamped to [4, 40]. |
height.max_rows | u16 | 18 | Ceiling on the bar's height. Clamped to [4, 60]. If min_rows > max_rows, the two are swapped on save. |
containers | list of lists | (see default above) | Outer length 1–4: one entry per equal-width column. Inner is a list of module IDs stacked vertically within the column. An empty inner list [] reserves an empty column. Empty outer list resets to default. Lengths > 4 are truncated to 4. |
Built-in module IDs: session_summary, recent_chat, processes,
recent_files. Unknown IDs render a [unknown: <id>] placeholder and
log a warning, so typos are visible but don't break the dashboard.
session_summary leads with the workspace's recap — the same
goal / state / next the Project Manager pane shows, one labeled
line per populated field, wrapped to the column:
SESSION SUMMARY
▸ goal: Audit all V2 invoices auto-issued
today for the CV-04964 drift bug
▸ state: 3 of 12 checked, drift on 2
▸ next: Fix rounding in issue_v2()
▸ Read×12 Edit×3 Bash×7
▸ working
▸ model: opus 5
Each field prefers the long form and falls back to the short one, so a
workspace whose agent only set --goal-short still gets a line. A
workspace with no recap at all falls back to the session's first user
prompt, which is what the module always showed before recaps existed.
Because the recap comes from the database rather than the session log,
it appears immediately — it doesn't wait on the loading… scan.
When every container is empty ([[], [], []]), the bar shrinks to its
4-row chrome (header + two rules + reply input) regardless of
height.percent. That's how you trim the bar to just the reply input.
Setting the global value
wsx config edit detail_bar_config # opens $EDITOR; seeded with the pretty-printed default
wsx config set detail_bar_config '{"height": {"percent": 50}}'
wsx config get detail_bar_config
wsx config set detail_bar_config "" # clear (reverts to baked-in defaults)
Partial JSON is fine — {"visible": false} is a complete, valid value.
Missing fields are filled in from defaults. Malformed JSON is rejected
with a non-zero exit and the previous value is preserved.
Examples:
# Make the bar taller on big monitors.
wsx config set detail_bar_config '{"height": {"percent": 45, "max_rows": 24}}'
# Single full-width chat column.
wsx config set detail_bar_config '{"containers": [["recent_chat"]]}'
# Four columns, processes and files in separate slots.
wsx config set detail_bar_config '{"containers": [["session_summary"], ["recent_chat"], ["processes"], ["recent_files"]]}'
# Hide the bar entirely.
wsx config set detail_bar_config '{"visible": false}'
Per-repo override
Each repo can override any subset of the global config. The per-repo
value is a DetailBarOverride — visible and height.* merge
per-field; containers is whole-replace when present, fully-inherited
when absent. An empty {} inherits everything; you only specify what
you want to change.
Open the repo settings modal with s on the dashboard, select the
detail_bar_config row, and press Enter. $EDITOR opens on {}\n
(or the current override). Save to apply; press d on the row to
clear the override and fall back to the global value.
Override examples:
Hide the bar entirely for this repo (global value can stay on):
{ "visible": false }
Single chat column for this repo; keep visible and height inherited from global:
{ "containers": [["recent_chat"]] }
Taller bar for a repo where the session-summary text is usually long (CLI tools with verbose tool-call traces):
{ "height": { "percent": 45, "max_rows": 28 } }
Merge precedence: bake-in defaults → global detail_bar_config →
per-repo override. visible and height.* apply per-field; containers
whole-replaces when the override sets it. So a repo override that only
sets containers still picks up any global height changes you make
later.
Behavior on bad input
- Malformed JSON at the global level — falls back to baked-in defaults at runtime, logged at
warn. - Malformed JSON in a repo override — the override is ignored; the global value applies, logged at
warnwith the repo name. - Out-of-range
height.percent/min_rows/max_rows— clamped to legal ranges on save (wsx config set/edit) and again at runtime as a defense-in-depth. min_rows > max_rows— swapped on save so the lower bound is the floor and the higher is the ceiling.
When you're attached to a workspace (full-screen claude session), wsx still tracks the other workspaces in the background. Two affordances surface that:
-
A one-row workspace list in the top bar, after the focused workspace's label. Every other workspace gets an entry,
<glyph> <repo>/<name> (<age>), with the same status glyph and color its dashboard row shows. Workspaces that need attention come first; the rest follow in the dashboard's own order under its current sort mode. Click an entry to switch to that workspace. When the row runs out of room it ends in… +N more; click that to open the updates panel described next. -
A floating panel via
Ctrl-x ulisting ALL workspaces — a stripped-down dashboard. Rows come in the dashboard's own order. Grouped by repo, each repo's rows follow the dashboard's sort mode (recency or status). Grouped by attention, the NEEDS ATTENTION / WORKING / RECENT / IDLE sections use the dashboard's fixed per-section order (urgency then age, or age alone), withrepo/namerows; the sort mode does not apply there, exactly as on the dashboard. Each row shows the workspace's current state and latest event, plus the same PR chip (⏺ #123 open ✓) and+N −Nline diff the dashboard row shows — in a terminal too narrow for both, the diff is dropped first, then the chip. Unlike the dashboard, nothing is folded or collapsed: every workspace is listed, and empty repos are skipped. PressEscto close — with a filter active,Escclears the filter first and closes on the second press. The panel re-renders live, so ages count up and attention flags appear/clear in real time.From the panel:
Key Action Up/Down(ork/j)Move selection within the panel; wraps from either end to the other. EnterSwitch the current pane to the selected workspace (replaces it). vOpen the selected workspace in a vertical split (panes side by side, vim's :vsplit).sOpen the selected workspace in a horizontal split (panes stacked, vim's :split).oCycle the dashboard's sort mode (recency ↔ status); persisted, like the dashboard's o.GToggle the dashboard's grouping (by repo ↔ by attention). /Filter the list; type to narrow it, Escto clear.The filter matches the workspace name, its repo's name, and the row's status text (the same text the row shows, case-insensitively), and repo headers with no surviving workspaces disappear along with their rows. While a filter is active, printable keys are filter text rather than shortcuts — so the arrow keys and
Enterare how you navigate and attach mid-search.
Multiple workspace PTYs can be tiled in the attached view, vim-style. Any
pane can be split again — recursively — into a tree of vertical and
horizontal splits. Each pane shows a 1-line title bar with the workspace
name and a ● marker on the focused pane (which receives keystrokes).
The flow:
- Attach to a workspace as usual (
Enteron the dashboard). - Press
Ctrl-x uto open the updates panel. - Move to another workspace; press
v(vertical) ors(horizontal) to add it as a new pane alongside the current one. Focus jumps to the new pane. - Navigate between panes with
Ctrl-x ←/→/↑/↓— direction-aware walking up the split tree, like vim'sCtrl-wmotions. - Close the focused pane with
Ctrl-x d. The other panes keep running; when the last pane closes you detach back to the dashboard.
When you split the focused pane again in the same direction as its parent, the new pane is inserted as a sibling instead of nesting deeper — matches vim and keeps the tree shallow.
Saving a layout. Ctrl-x d detaches without remembering how the panes
were arranged. To keep the arrangement, press Ctrl-x Shift-D instead: wsx
saves the split tree (and which pane was focused) against the anchor
workspace — the first pane you attached to — then detaches to the
dashboard. (Ctrl-x Esc just dismisses the navigation overlay and leaves
you attached.) The next time you attach to that workspace, wsx restores the
layout and respawns the side panes' sessions. Panes whose workspaces no
longer exist are pruned on restore; if none survive you get a plain
single-pane view. Workspaces with a saved multi-pane layout show a columns
glyph next to their branch on the dashboard (nerd fonts only).
Press p on the dashboard to open the project-manager digest: a horizontal
pane below the workspace list that instantly lists every Ready workspace,
grouped by repo. There's no agent session behind it — the digest is rendered
directly from wsx's own state (recaps, pushed status, git counts, PR
lookups), so it opens with no delay and nothing to configure.
p opens the digest and focuses it immediately (like the attached view).
Tab or Esc swaps focus back to the dashboard; Tab from the dashboard
swaps back into the digest. p closes it from either focus. q also
closes it, but only while the digest is focused — dashboard-focused q
quits wsx entirely (killing running sessions), so don't reach for q to
close the digest unless focus is already on it.
Within each repo group, cards are ordered by what needs attention first: blocked workspaces, then waiting workspaces, then the rest oldest-activity- first.
What a card shows
- Header line: workspace name, branch, and coding agent, plus the
agent-pushed status in brackets —
[blocked 4s],[waiting 12m], etc. — with its message appended when the agent reported one. This is the same status set viawsx status set. - Recap lines —
goal:,state:,next:— the agent's own account of what the workspace is for, where it's at, and what's left. Only fields the agent has actually set are shown. Workspaces whose agent hasn't run since this feature landed (or that have never had a recap written) showno recap yet — agent hasn't run since this feature landedinstead. - Facts line: git counts (
↑ahead ↓behind ~modified ?untracked), a PR chip (PR #241 open,PR #241 draft,PR merged, …) colored by lifecycle,active <age> agofrom the workspace's last session activity, and arecap stalemarker when that activity is newer than the recap — a sign the agent moved on without updating it.
Where recaps come from
Each workspace's own agent maintains its recap with wsx recap set:
wsx recap set --goal "fix auth"
wsx recap set --state "tests failing" --next "debug the regex"
Any subset of --goal, --state, and --next can be set at once (at
least one is required); omitted flags leave the existing value untouched.
wsx recap show prints the current recap, and wsx recap clear deletes
it. This isn't something you normally run by hand — the standing operating
doctrine wsx injects into every session (see process_doctrine in
Global settings) instructs the agent
to set the goal once scope is clear and refresh state/next alongside its
status updates.
Keys
| Key (digest focused) | Action |
|---|---|
j / k (or arrows) | Move selection |
Enter | Attach to the selected workspace |
/ | Filter cards by workspace name (type to narrow) |
Esc / Tab | Clear the filter (if active) / return focus |
q / p | Close the digest |
r | Force a git/PR cache refresh |
| Key (dashboard focused) | Action |
|---|---|
p | Toggle the digest |
Tab | Focus the digest (when visible) |
r (with digest visible) | Force a git/PR cache refresh |
Global settings, themes, agent selection, and per-repo customization.
wsx config get <key>
wsx config set <key> <value-or-@file>
wsx config list
wsx config edit <key> # opens $EDITOR (default: vi)
Known keys:
| Key | Effect |
|---|---|
branch_prefix | Default branch prefix for repos with no per-repo override. Branches are named <prefix>/<workspace>. |
custom_instructions | Free-text appended to claude's system prompt on every workspace spawn. |
process_doctrine | Standing "operating doctrine" injected into every developer session (new and resumed) across all agents: think and plan before scope is set, break work into logical commits, load the wsx skill, report status with wsx status set, and maintain the workspace recap with wsx recap set (see Project manager digest). Set this to replace the default text verbatim (@file supported); set it to off / none / disabled to suppress injection entirely. A blank value restores the default (it is not an off switch). |
process_doctrine_extra | Extra clauses appended to the end of the effective doctrine (default or process_doctrine override), one per line in the same - ... bullet style, for every agent. Use it to add per-install practices without freezing a copy of the whole default text — e.g. wsx config set process_doctrine_extra "- Use the superpowers skills by default when evaluating the initial request; drop them if the task turns out not to need that level of planning." (@file supported). Ignored when process_doctrine is set to a disable sentinel. |
coding_agent | Default coding agent for new workspaces: claude (default) / pi / hermes / codex / omp. Per-workspace override via wsx workspace create <repo> --agent <agent>. See Coding agents. |
nerd_fonts | Render nerd-font glyphs in the dashboard. Default ON; set to false / 0 / off to disable. |
editor_cmd | Command to run for [e] edit on the dashboard. Worktree path appended as final arg unless the command contains {path} (substituted in place). Examples: code, cursor, alacritty -e nvim, xdg-terminal-exec --dir={path} nvim. Also required for the chronology bar's "open at changed line" action; see Change chronology for the {file}/{line} injection details. |
terminal_cmd | Command to run for [t] terminal on the dashboard. Spawned with cwd=worktree; {path} substituted in place if present. Examples: alacritty, kitty, gnome-terminal. |
notifications | Ring the terminal bell and show a ! marker when a workspace transitions to waiting (claude paused for ≥30s). Default ON; set to off / false / 0 / no to disable. |
notification_bell_question | Bell pattern when a workspace's agent asks you a question. One of off, single, double, triple (anything else is rejected by config set). Default double. The ! attention marker still appears when set to off; use notifications to silence everything. |
notification_bell_complete | Bell pattern when a workspace's agent finishes a turn. Same values. Default single. |
notification_bell_permission | Bell pattern when a workspace's agent is waiting on a permission prompt. Same values. Default single. |
notification_bell_stalled | Bell pattern when a workspace's agent has stalled. Same values. Default triple. |
theme | Base color palette. One of wsx (default), default (palette-adaptive ANSI), dracula, jellybeans, nord. Unknown values fall back to wsx. Restart wsx after changing. Bar layout and styling live in ~/.config/wsx/theme.toml; see Themes. |
bar_theme | Opt-in for the bar theme file. Default OFF: wsx draws its stock bars and never reads ~/.config/wsx/theme.toml. Set to on / true / 1 / yes to honor the file (live reload, error notice); back to off snaps to the stock bars. Takes effect within a second, no restart. See Themes. |
mcp_mirror | Inherit MCP servers from the source repo into worktrees (see MCP server inheritance). Default ON; set to off / false / 0 / no to disable. |
remote_control | Pass --remote-control to claude on every spawn so the session is reachable via claude.ai/code and the Claude mobile app (see Remote control). Default ON; set to off / false / 0 / no to disable. |
remote_control_sandbox | When remote_control is on, also pass --sandbox for an extra safety wrapper on remote-issued commands. Default OFF; set to on / true / 1 / yes to enable. |
pinned_commands | Newline-separated list of Label=command (or bare command) entries. Each becomes a chip in the attached view, fired via Ctrl-x <digit> or click; a command ending in ... is typed but not submitted. Max 9 visible/keyable. Per-repo override available via wsx repo set-pinned-commands. |
prompt_tags | Newline-separated name=uses entries — the saved prompt tags and their use counts, maintained by the attached view's Ctrl-x < picker. Editable by hand; malformed lines (an invalid name, a non-numeric count) are dropped on read and disappear on the next save. |
remotes | Newline-separated list of name=command entries — named shell commands run by wsx remote <name>, typically ssh -t host '…tmux attach…' for reattaching a wsx session running on another machine. List with wsx remote; add or edit with wsx config edit remotes. See Named remote shortcuts. |
dashboard_branch_width | Width (chars) of the ⎇ branch column on the dashboard. Default 28. Clamped to 10..=80. |
dashboard_pr_width | Width (chars) of the PR chip column (⏺ #123 open) on the dashboard. Default 16. Clamped to 8..=24. |
dashboard_sort_mode | How workspaces are ordered inside a repo on the dashboard: recency (default) or status. Toggled live with o, which writes this setting; a CLI change applies at the next wsx start. |
dashboard_blocked_pin_max_age_secs | How long a workspace blocked on you (? question, ! stalled) is pinned above the recency-ordered rows. Default 86400 (24h). Past this it sorts on age like anything else, so a workspace parked blocked for weeks stops camping at the top. Only applies in recency mode. |
detail_bar_config | JSON blob controlling the per-workspace detail bar (visibility, height, and the container/module layout). See Workspace detail bar for the schema, defaults, and per-repo override flow. Out-of-range values are clamped on save. |
chronology_config | JSON blob controlling the change chronology bar in the attached view (visibility, side, and width). See Change chronology for the schema, defaults, and per-repo override flow. |
Value sources:
- A literal string:
wsx config set branch_prefix bakedbean - A file (prefix with
@):wsx config set custom_instructions @./instructions.md - Empty (clears):
wsx config set custom_instructions ""
wsx config edit <key> opens $EDITOR on a tempfile prepopulated with the current value; saving updates the setting. Useful for multi-line custom_instructions.
Themes
wsx has two layers of theming: a base palette chosen with the theme
setting, and a bar theme file that describes what the dashboard header
and footer, the attached view's top and bottom bars, and the dashboard
detail pane's pinned-command row contain and how each piece is styled,
using a subset of Starship's format grammar.
Base palette
wsx config set theme dracula
wsx config set theme jellybeans
wsx config set theme nord
wsx config set theme wsx # default
wsx config set theme default # ANSI colors that follow your terminal
The base palette colors repo headers, the selected row, status dots, modals, and markdown. Restart wsx after changing it. Its colors are also available to the bar theme file as theme tokens (below). The bar theme file does not change the base palette or the appearance of other UI elements.
Bar theme file
Ready-to-use examples live in the repo under docs/examples/:
| File | Look |
|---|---|
theme-starship.toml | Powerline blocks in six stepped greys with orange accents, in the style of a starship prompt. |
theme-rose-pine.toml | Rosé Pine (main) in an airline layout. |
theme-rose-pine-moon.toml | Rosé Pine Moon, the softer, slightly lighter dark variant, same layout. |
theme-nord.toml | Nord, same layout. |
theme-nord0.toml | Nord one step darker: base blocks on nord0, so the middle of each bar melts into a nord terminal background. |
theme-jellybeans.toml | Jellybeans, same layout. |
theme-orange.toml | Dark orange, converted from a vim-airline theme: an orange block at each edge, then the greys stepping up from near-black toward the middle. |
The Rosé Pine, Nord, and Jellybeans files share one arrangement and differ
only by palette: generally a bright "mode" block at each outer edge, a
mid-toned block beside it, and a base-toned block toward the middle. The
attached bottom bar instead starts with a dark Menu block. Orange keeps
the airline layout but splits more pieces into
their own blocks (view, repos and workspaces, and each item on the attached
bottom bar's right side). Across all seven example themes, the attached
bottom bar starts with Menu on the same dark background as the top agent
block, followed by separate pinned-command and Tags blocks. Orange keeps
its blank orange stub only on the dashboard footer.
In every file the dashboard header's wordmark stays flat on the bar in the
app's brand colours (the bundled default's blue bar and "x"), so the left
chain starts at the block beside it rather than on a mode block.
The two Nord files pair with wsx config set theme nord, and Jellybeans and
Orange with wsx config set theme jellybeans, so the rest of the UI
matches; Rosé Pine has no built-in base palette, so leave the default wsx.
To use one, copy it to ~/.config/wsx/theme.toml, turn the feature on, and
validate:
cp docs/examples/theme-starship.toml ~/.config/wsx/theme.toml
wsx config set bar_theme on
wsx theme check
To keep several on hand and switch between them, copy the files somewhere
stable and make theme.toml a symlink you re-point. wsx fingerprints the
file the link resolves to by its modification time, size, and permission
bits, so re-pointing it reloads the bars within a second in the running
app. Two targets with identical metadata would not be told apart; if a
switch ever fails to show, touch the file the link now points at.
mkdir -p ~/.config/wsx/themes
cp docs/examples/theme-*.toml ~/.config/wsx/themes/
ln -sfn themes/theme-nord.toml ~/.config/wsx/theme.toml # switch
ln -sfn themes/theme-rose-pine.toml ~/.config/wsx/theme.toml # switch again
They need a Nerd Font (for the / caps) and a truecolor terminal.
Those caps are private-use characters (U+E0B0 and U+E0B2), so copy the files
as bytes (cp, scp, a dotfiles repo) rather than pasting them through a
chat or editor that strips unknown glyphs; if they go missing, wsx theme check still passes but the blocks render with flat edges.
The bar theme is opt-in. Turn it on with
wsx config set bar_theme on
and off again with wsx config set bar_theme off (the default). While off,
wsx draws its stock bars and never reads the file; the wsx theme commands
below still work, so you can prepare and validate a file before enabling it.
The setting is re-read once a second, so switching either way takes effect
in the running app without a restart.
wsx theme path # where wsx looks: ~/.config/wsx/theme.toml
wsx theme init # write the bundled default there (never overwrites)
wsx theme check # validate and print every error; exit 1 on any
The path honors XDG_CONFIG_HOME: when set to an absolute path, the file is
$XDG_CONFIG_HOME/wsx/theme.toml; a relative or unset value falls back to
~/.config/wsx/theme.toml. wsx theme check [path] can also validate
another file before you install it.
The file is optional. Anything you leave out keeps the bundled default,
which preserves wsx's stock bar content and styling, so wsx theme init
gives you a commented starting point. wsx checks for changes once a second
and reloads edits while running. If a save has an error, the bars keep their
last good look; in its place, the dashboard footer shows the first error in
red for five seconds ((+N more) when there is more than one), and the
full list goes to the log. An invalid file at startup falls back to the
bundled default.
The loader reports every field-level problem it finds in one pass — an unknown segment, a bad color, a variable a format isn't allowed to use, and so on, each with its own location. A TOML syntax error or a format-string parse error is different: it stops parsing right there, so only the first such error in that string is reported, not every one that string might contain.
Differences from the stock bars
A handful of narrow, accepted gaps between the engine and the bars it replaced:
- A workspace with no PR leaves one blank cell at the chip row's right
edge instead of hugging it exactly (the stock chip row's
$pris bare, with no trailing separator of its own). - When the dashboard footer is too narrow for its key hints plus the
right side, it drops the version string first, then the
funnelmodule, instead of overflowing the terminal width. Where that happens depends on how much the funnel has to say, since each stage renders only while its count is non-zero. - A pinned chip clipped by the right edge keeps its visible portion clickable, rather than being dropped in full.
- The stock attached view draws a dim
─rule under its top bar to set it off from the pane. Withbar_themeon, the themed bar's own blocks do that job, so the rule row is dropped and the pane gains a row. - The dashboard header's filter echo is capped at 24 characters and never
shrinks further. The stock header instead budgeted the needle against
whatever room was left on the line, so the repo/workspace counts always
survived. Now a long needle costs the counts (
priority50) first, and below roughly 80 columns the header runs long and is clipped at the right edge. Why the echo itself never drops is unchanged: a needle with no visible cause is worse than a truncated one — rows are missing from the list and nothing on screen says why.
Bars
[dashboard_header]
format = "$brand $group( $sort)( $filter)"
right_format = "$counts"
fill = " "
[dashboard_footer]
format = "$keys"
right_format = "$funnel"
[attached_top]
format = "($agent_bar )$workspace( $attention)"
[attached_bottom]
format = "$keys ($pins )($tags )"
right_format = "( ($agents )($model_tokens )($procs )($diff )$pr)"
fill = "─"
fill_style = "fg:dim"
[dashboard_detail]
format = "($pins )"
fill = "─"
fill_style = "fg:dim"
[dashboard_footer]'s right side is the bundled funnel module — see
Modules below for what a module is and how to replace or
restyle it. $version (the running wsx version) is registered but not
placed; right_format = "$version( $funnel)" brings it back. There the
group around $funnel and its leading two spaces means that separator drops
along with $funnel itself when the fleet is empty or the funnel is dropped
for width — the same "put separators inside the group" rule described under
Grammar below. ($version's lower priority means it is the first of the two
to go on a narrow footer.)
[dashboard_header] is the dashboard's top line: the wordmark, the group:
and sort: mode tabs, the live filter echo, and the repo/workspace counts
flush right. Its five segments are display only — nothing on that line is
clickable.
A theme that sets its own [attached_bottom].format keeps that layout
unchanged — add $tags to it yourself to get the prompt-tag chips (the
keyboard chord works either way).
[dashboard_detail] is the dashboard's own DETAIL pane (the pane shown when
a workspace row is selected, distinct from the attached view): its
pinned-command chip row, followed by a rule to the edge. $pins is the only
data-bearing segment there — every other registered segment renders empty if
you put it in this bar's format.
| Key | Meaning |
|---|---|
format | The left side, clipped at the right edge if too long. |
right_format | Flush right. |
style | Base style inherited by literal text, segment content, and the fill; inner styles can override it. |
fill | The first character repeated across the unused gap. |
fill_style | Style for the fill, merged over the bar's base style. |
When both sides are nonempty, at least one column between them stays blank,
even with a visible fill character. This blank column counts when deciding
whether the right side fits. The fill occupies the remaining gap.
Overflow. When a bar is too narrow for both sides plus that blank
column, segments with a priority below 100 may drop — from either side,
lowest first, re-evaluating both sides after each removal so conditional
groups shed their separators with them — until the bar fits or nothing
droppable is left. Segments at the default priority (100) never drop. If
the sides still don't fit after that, the right side is omitted entirely
rather than partially rendered, and the left side is clipped at the right
edge.
Grammar
| Syntax | Meaning |
|---|---|
$name, ${name} | Insert a segment (inside a segment's format: one of its variables). |
[text](style) | Style a run. Inner runs inherit the outer background unless they set their own, so powerline blocks compose. |
( … ) | Render only if a $name inside produced output. Put separators inside the group so they vanish with the segment. |
$$, \[, \(, \x | Literal characters: $, [, (, or the escaped character x. |
In TOML double-quoted strings, write a backslash as \\; TOML single-quoted
literal strings can contain format escapes directly, such as '\[$workspace\]'.
A style is space-separated tokens: fg:<c>, bg:<c>, a bare <c>
(foreground), bold, dimmed, italic, underline, none, and $style
(the segment's resolved style, see below). none is accepted for Starship
compatibility but does nothing: it is a no-op, not a reset, so it neither
clears inherited attributes nor cancels other tokens in the same style.
A color is #rrggbb, a 0–255
index, an ANSI name (red, bright-blue, white), a [palette] name, or
a theme token: dim path code bg_alt bg_soft ok warn err attention merged header_fg selected_fg selected_bg question stalled waiting thinking complete idle brand wordmark agent_claude agent_pi agent_hermes agent_codex agent_omp
(the agent_* tokens are each agent kind's fixed identity colour, the same
in every theme). Palette names shadow theme tokens, which shadow ANSI names.
Shadowing changes color lookup in the bar theme only; it never changes the
base Theme fields. fg:dim selects a color; dimmed is a text modifier.
Segments
Each segment has its own table, such as [workspace], with format (its
layout, using the variables below), style, symbol, disabled,
priority (overflow survival; higher lasts longer; unset defaults to 100,
which never drops), a palette sub-table (see
Recolouring one segment), and, for multi-item
segments, separator and styles. A multi-item segment's format
describes one item; the items keep their existing order.
separator is a format too — "[ │ ](fg:dim)" draws a dim joiner — but
it sits between items rather than inside one, so it takes no variables and
no $style. Because it is parsed with the grammar above, a separator that
wants a literal $, [, (, or backslash must escape it ($$, \[,
\(, \\), and a bare (x) is a conditional group that renders nothing;
a plain run of spaces or box-drawing characters needs no change. Two
segments also take more_format: on attention it is the tail drawn
when entries don't fit the bar, on tags the manager chip that always
follows the chips; its one variable is $count (entries folded, or tags
saved), and setting it on any other segment is an error. attention
alone takes more_style, the tail's own $style (see the caps below);
tags' chip has none. Likewise agent_bar
alone takes a symbols sub-table, one glyph per agent kind, tried ahead
of symbol:
[agent_bar]
symbol = "\ue0b0" # kinds without an entry below (Nerd Font chevron)
[agent_bar.symbols]
claude = "\uec82"
codex = "\uec81"
Keys must be agent kind names; any other key, or the table on another
segment, is an error. Entries union per kind over the bundled default,
yours winning, like a segment palette. An empty entry (pi = "") is an
override, not an absence: that kind shows no glyph rather than symbol.
The agents pills read the same table through their $icon variable,
and a [module.<name>] format through $icon_<kind>, so each harness's
glyph is drawn once and appears everywhere the theme names it.
An item whose format renders empty — an empty format, or one whose
variables are all absent for that item — is dropped as if it were never in
the list: no separator, no click target, no grade, and not counted in the
tail.
Grading items by position
A multi-item segment also takes styles, a list of style strings: the
first rendered item's $style is the segment's usual style patched by
styles[0], the second's by styles[1], and so on, with items past the
end of the list all taking its last entry. A bg-only grade keeps the
provider's state colour in the foreground (an attention entry's
PR-lifecycle tint, an agent pill's identity colour). Positions count
rendered items, so an empty item takes no grade with it, and an attention
entry folded into the tail does not either.
To draw powerline caps between graded blocks, the formats of a multi-item
segment may name six extra colours: item_fg/item_bg are the item's own
final $style colours, prev_* and next_* those of its rendered
neighbours. separator sees prev_* and next_* (the items on each side
of it); more_format sees prev_* (the last rendered entry). A colour
that does not exist — the first item's prev, the last rendered item's
next, or a grade that never set that colour — carries nothing: that
token sets nothing, whatever $style or an enclosing run already set
stays, and where nothing set it the bar's own style shows through. That
is what lets the last block's trailing wedge blend into the bar without
the theme knowing how many entries there are, provided the wedge sits
outside the graded background run (as below), not inside it.
attention's fold tail joins the run through more_style: a style
string patched over the segment's style, like a grade. It is the tail's
$style and its item_* colours, and the last rendered entry's next_*
when the tail follows it — so that entry's trailing wedge points into the
tail, and into the bar only when the entry really is last. Without
more_style the tail has no colours of its own: $style there is empty,
and the last entry's next is absent even when a tail follows. Like
styles, it may not name the six colours.
[attention]
styles = ["bg:charcoal fg:orange", "bg:slate fg:cream", "bg:grey fg:cream"]
more_style = "bg:orange fg:black"
format = "[ $glyph $repo/$name \\($age\\) ]($style)[\ue0b0](fg:item_bg bg:next_bg)"
separator = ""
more_format = "[ +$count more ]($style)[\ue0b0](fg:item_bg)"
Here each block, entry or tail, carries its own trailing wedge, coloured
from its block into the next; the first block's leading cap belongs in
the bar format, where styles[0] is known. The formats are TOML double-quoted strings so
that \ue0b0 decodes to the wedge glyph and \\( reaches the grammar as
\(; in a single-quoted literal string \ue0b0 would stay as typed and
render as the five characters ue0b0. styles entries may not use the
six names themselves (a grade cannot depend on the neighbours that depend
on it), wsx theme check rejects styles and the six names on a
single-item segment, and the six names are reserved: a [palette] entry
by one of them is an error, since inside a multi-item segment it would be
shadowed by the per-item colour.
The bundled default sets priority on the segments that compete for room:
model_tokens 10, agents 20, procs 30, diff 40, pr 50 (the
attached chip row's right side); tags 40 (the same row's left side);
version 50, the funnel module 60 (the dashboard footer's right side,
and usage keeps its 60 for a theme that places it back); sort 30,
counts 50 (the dashboard header). Every other segment is the unset
default, 100, and so never drops.
| Segment | Variables | Notes |
|---|---|---|
brand | $symbol $name $mark $view | The wordmark. $name is workspace, $mark is x, $view names the view (dashboard). Dashboard header only. |
group | $label $tabs | The group: mode tabs; $tabs is opaque, with the active mode highlighted. Dashboard header only. |
sort | $label $tabs | The sort: mode tabs, same shape as group. Dashboard header only. |
filter | $needle | The live filter echo, absent when no filter is active; $needle is capped at 24 characters. Dashboard header only. |
counts | $repos $workspaces | Registered repo and workspace counts. Dashboard header only. |
keys | $key $label | One pill per key hint. Clickable. |
version | $version | |
usage | $label $spark | The activity sparkline. Clickable. |
agent_bar | $symbol | $style includes the agent's identity color. $symbol is the focused agent's entry in [agent_bar.symbols] (keys claude, pi, hermes, codex, omp) when the theme sets one, else symbol. Attached only. |
workspace | $repo $name | $repo is absent when there is no repo name. $style includes the PR-lifecycle tint (green open, purple merged, red closed), or the header style without a PR. Attached only. |
attention | $glyph $repo $name $age | One item per workspace needing attention. $glyph is the entry's dashboard status glyph in its status color; $style is the name's PR-lifecycle tint (open, merged, …) or the muted path hue. Entries that don't fit fold into more_format ($count); the first entry always renders, and if it alone would push the tail off the bar its $name is shortened with an ellipsis (assuming one $name in the format; a format without $name, or a very long $repo, has nothing to yield and simply clips). Clickable: each entry, and the tail. |
pins | $index $label | One chip per pinned command. Clickable. |
tags | $index $label | The three most-used prompt tags as chips, then the manager chip from more_format ($count = saved tags). Attached only. Clickable: each chip, and the manager. |
agents | $symbol $icon $label $key | One pill per agent (2+ agents). $style includes the agent color. symbol is ignored — the pill always uses a filled/hollow dot to show which agent is active. $icon is the pill's kind's entry in [agent_bar.symbols], absent for a kind without one. Clickable. |
model_tokens | $model $tokens | $style includes ok, or warn near the context limit. |
procs | $symbol $count | Hidden at zero. Clickable. |
diff | $added $removed | Hidden when clean. |
pr | $symbol $number $label $mark | $style includes the lifecycle tint; $mark_style supplies the review verdict style. Clickable — except over a remote (ssh) attach, where the chip still renders but isn't clickable (opening a PR keys off a local workspace id a remote attach doesn't have). |
pr, procs, usage, attention, and tags may each be placed only
once: pr, procs, and usage carry exactly one click target; attention
and tags, though each records one hit per entry/chip like
pins/agents/keys, also carry a single tail target — … +N more for
attention, the manager chip (more_format) for tags — and each is
fitted to the one bar that places it. Put one of these five in more than
one place across the two attached bars' format/right_format (or twice
within the dashboard footer's own format/right_format, or twice within
the dashboard header's, or twice within the dashboard detail pane's) and
only the last-routed placement would be clickable, so wsx theme check
rejects it as a duplicate instead. These four scopes are independent: a
singleton segment may appear once in each without conflicting with the
others.
All segments are available in either attached bar, on either side;
click targets follow them between bars as well as within a bar. version
and usage work in all three bars, not just the dashboard footer — put
$usage in an attached bar and its sparkline is the same graph, clickable
the same way. keys uses the attached view's leader-key hints in both
attached bars. On the dashboard footer, only keys, version, and usage
produce output; on the dashboard header, only brand, group, sort,
filter, and counts; on the dashboard detail pane's row, only pins;
other segments render empty in each. Segments also render empty when their
underlying data is absent.
Two details of the stock formats are worth knowing before you override them:
styleonly reaches the output through$style. The stock formats ofagent_bar,workspace,attention,agents,model_tokens,procs, andprbind it ([…]($style)), so settingstyleon those works as written. The stock formats ofkeys,pins,version,usage, anddiffstyle their parts directly instead, so a barestyle = …on one of those has no effect unless you also put$stylein itsformat.- Bare parentheses are the conditional-group syntax, so a literal pair
must be escaped. The stock
attentionitem format writes its age as[ \($age\)](fg:dim)in a TOML literal string for exactly this reason; an unescaped($age)renders the age without the parentheses. - Upgrading a theme written before
attentionbecame multi-item: its old[attention] format = "$items"is now rejected as an unknown variable — delete the table to take the stock item format, or rewrite it with the variables above. There is no$itemsalias. symbolis substituted intoformat, but the literal spacing around it stays. Emptying one ([pr]symbol = "") leaves the space that follows$symbolin the stock format; delete that space informattoo if you want the glyph gone entirely.
Overriding a segment's style
A segment's style is merged over its state-derived default attribute by
attribute and exposed as $style inside style expressions in that
segment's format. For example, style = "bg:second" preserves the PR's
state-derived foreground, while style = "fg:rust" replaces it. Use
[$name]($style) in a custom segment format to apply that resolved style:
[palette]
rust = "#d75f00"
[workspace]
format = "[($repo/)$name]($style)"
style = "fg:rust bold"
Here rust must be defined in [palette], as in the example below. Setting
a segment's style does not forcibly recolor every nested run: an explicit
style on an inner run can override inherited attributes. Likewise, putting
[$workspace](fg:rust) in a bar format supplies an inherited foreground,
not an override of the segment's own foreground. For the PR review mark,
use [$mark]($mark_style) to retain its separate verdict color rather than
applying the lifecycle style to it.
Recolouring one segment
[palette] names shadow theme tokens everywhere in the file. A segment
can carry its own [<segment>.palette] too, with the same value grammar
(plus: a value may name a global [palette] entry, so ok = "green" is
the theme's own green rather than ANSI's), that shadows both the global
palette and the theme tokens inside that segment only — for colour names in its format, style, styles,
separator, and more_format, and for the tokens behind its
state-derived $style. This is how a theme darkens the lifecycle tints
on a light block without changing them on a dark one: the same ok that
tints an open PR green on the dark attention run is too pale on a bright
"mode" block, so the segments that sit there take darker greens of their
own.
[palette]
orange = "#d75f00"
[attached_bottom]
right_format = "[$pr](bg:orange)"
[pr.palette]
ok = "#008700" # open, darker than the theme's `ok`
merged = "#870087"
err = "#870000"
warn = "#878700" # conflict
[workspace.palette]
ok = "#008700"
merged = "#870087"
err = "#870000"
warn = "#878700"
header_fg = "black" # the no-PR fallback keeps the block's black text
The overlay is only a colour lookup: it cannot add attributes or change
which token a state uses (ok for open, merged, err for closed,
warn for conflict; header_fg for workspace without a PR or on a
draft, dim for the pr chip on a draft; the six status tokens for
attention's $glyph; selected_fg/selected_bg/path for the
group/sort tabs). This is the one place a palette reaches a
state-derived $style: the global [palette] shadows tokens only where a
format names them (fg:ok), never the colour a segment derives for its
own state. A segment palette's values may reference [palette] names but
not each other, so there are no local aliases. A name defined only in a
segment's palette is unknown outside it, so wsx theme check reports a
bar format that uses one. The six per-item names are reserved here as in
[palette].
Modules
A module is a segment you compose yourself. Declare it as a
[module.<name>] table and place it in any bar as $<name>:
[module.funnel]
format = "([$working working](fg:ok) )([$blocked blocked](fg:err) )([$mergeable ready](fg:merged))"
priority = 60
[dashboard_footer]
right_format = "$funnel"
A module table takes format, style (patched over the bar style to form
$style), priority, and disabled — nothing else, since a module has no
items. Its format may reference only the fleet variables below, which
describe every workspace on the dashboard at once. A count renders empty
when it is zero, so wrap each item in a ( … ) group to drop it along with
its label and gap; $workspaces and $repos always render a number. The
tokens_* variables are sums of context size rather than counts; they render
abbreviated (77k, 1.2M) and are likewise empty at zero.
A module's name may not be the name of a built-in segment (keys, usage,
pr, …). The bundled default defines two modules: funnel, placed where
the usage graph used to be, and tokens — context fill per agent kind
(claude 1.2M codex 340k), defined but not placed. Set only the fields
you want to change to restyle either, or define your own and put that in
the bar instead. To show the token module, or bring the sparkline back,
place $tokens or $usage:
[dashboard_footer]
right_format = "$tokens( $funnel)( $usage)"
Modules carry no click target.
| Variable | Counts |
|---|---|
working waiting blocked done | workspaces whose last wsx status set is that state |
busy | workspaces parked on background work (hook-inferred) |
unreported | workspaces with no reported status |
alerts | workspaces with an unacknowledged attention alert |
awaiting stalled active idle | workspaces by live transcript classification |
live_agents | workspaces with a live (thinking or waiting) primary session |
pr_none pr_draft pr_open pr_conflicted pr_merged pr_closed | workspaces by PR lifecycle |
review_required changes_requested approved | PRs by review verdict |
unresolved | unresolved review threads across the fleet |
mergeable | PRs that are open and approved |
dirty | workspaces with modified or untracked files |
msgs_queued | agent-to-agent messages not yet delivered |
workspaces repos | totals (always rendered) |
tokens_total | Σ latest reported context size (prompt-side tokens) across every agent instance whose transcript is still cached, primary and peers |
tokens_claude tokens_pi tokens_hermes tokens_codex tokens_omp | the same, per agent kind (hermes reports no usage, so it is always empty) |
icon_claude icon_pi icon_hermes icon_codex icon_omp | the kind's glyph from [agent_bar.symbols] — the theme's, not the fleet's; absent for a kind without an entry. A label: it renders beside a count but, like literal text, never keeps a ( … ) group alive by itself, so ([$icon_pi $tokens_pi]) drops with the count exactly as [pi $tokens_pi] does |
A powerline example
These examples use the real rounded powerline caps and ; your
terminal font needs to include them. Conditional groups hide whole blocks,
including their caps and spaces, when the enclosed segment is empty.
[palette]
first = "#121212"
second = "#3a3a3a"
rust = "#d75f00"
[attached_top]
format = "[](fg:first)[ $workspace ](bg:first)[](fg:first)( [](fg:second)[ $attention ](bg:second)[](fg:second))"
[attached_bottom]
right_format = "([](fg:second)[ $agents ](bg:second)[](fg:second))( [](fg:first)[ $pr ](bg:first)[](fg:first))"
[workspace]
format = "[($repo/)$name]($style)"
style = "fg:rust bold"
[pr]
style = "fg:rust"
After your first prompt in a freshly-created workspace, wsx renames the workspace + git branch based on the conversation. Controlled by WSX_RENAME_MODE:
| Mode | Behavior |
|---|---|
claude (default) | Claude itself runs git branch -m as the first action in its response, based on your first message. A background poller propagates the rename to the wsx store. Higher-quality slugs at the cost of ~80 tokens per session start. |
local | wsx intercepts your first prompt's keystrokes locally and slugifies them. Zero tokens; literal text. |
off | No auto-rename. Workspaces keep their generated <adjective>-<plant> name forever. |
The rename only fires on workspaces whose name still matches the generated <adjective>-<plant> pattern.
When an agent is actively editing files, it's easy to lose track of what changed, where, and when — especially across a long session with many small edits. The change chronology bar is a toggleable vertical panel docked to the side of the attached view that rebuilds your spatial and temporal memory of what the agent touched.
The bar shows a newest-first, time-ordered list of individual file edits the agent made — one entry per change, not per commit. Each entry is a single line: the time and the file path. Long paths are abbreviated by collapsing the ancestor directories to their first letter, keeping the parent directory and filename readable (e.g. docs/superpowers/specs/2026-06-05-foo.md shows as d/s/specs/2026-06-05-foo.md). Press Enter on an entry (or click it) to open the full-change detail modal, a scrollable overlay showing the complete diff with a line-number gutter — added (+) lines are numbered with their current file line (the same line the editor opens to), while removed (-) lines show a blank gutter.
Currently the chronology is reconstructed from Claude Code's on-disk session logs. Support for other agents is added incrementally as those log formats are covered.
Keyboard navigation
The chronology bar is a focusable pane. While attached, press Ctrl-x then an arrow key toward the bar's side to move keyboard focus into it (bar on the right → Ctrl-x →; bar on the left → Ctrl-x ←). This only works from the edge pane adjacent to the bar; otherwise Ctrl-x+arrow keeps moving between agent split panes as normal.
While the bar is focused, keystrokes are captured by the bar and do not reach the agent:
↑/kand↓/jmove the selection;gjumps to the top (newest),Gto the bottom.Enteron an entry opens the full-change detail modal for that entry.Esc(orCtrl-x+ arrow away from the bar's side) returns focus to the agent pane.
Detail modal
The modal is a scrollable overlay showing the full diff of the selected change:
- Scroll with
↑/↓,j/k,PgUp/PgDn,g/G, or the mouse wheel. - Press
eto open the file in your editor at the changed line (requireseditor_cmd— see below). - Press
Escor click outside the modal to close it and return to the bar.
The diff is displayed with basic syntax highlighting for Rust, Python, Shell, and a generic C-like family (C/C++/JS/TS/Go/Java/JSON, and similar); other file types are shown plain. Added (+) lines are tinted green and removed (-) lines red; the line-number gutter stays dim. Highlighting is per-line — multi-line strings or block comments may not be perfectly colored.
Keybindings (attached view, under the Ctrl-x leader)
| Key | Action |
|---|---|
Ctrl-x c | Toggle the chronology bar on/off |
Ctrl-x C | Swap the bar's side (left ↔ right) |
Mouse wheel over the bar scrolls it. Click an entry to focus the bar, select the entry, and open the detail modal.
Opening a file at the changed line
Pressing e inside the detail modal opens the file in your editor, jumping directly to the modified line.
editor_cmd is required for this action. If editor_cmd is unset, wsx shows a dismissible prompt telling you to configure it. There is no silent fallback to $VISUAL or $EDITOR for this specific action — those env-var fallbacks still apply to the separate [e] / Ctrl-x e "open workspace in editor" actions, which are unchanged.
File and line injection. When editor_cmd is set, wsx injects the file path and line number at runtime using one of two strategies:
- Placeholders: if your command contains
{file},{line}, and/or{path}, they are substituted in place.{path}is the worktree root (the same value substituted by the[e]dir-open action), so a singleeditor_cmdworks for both actions. Use placeholders for editors wsx doesn't recognize or when you need exact control over argument order. - Auto-detection: if no
{file}or{line}placeholders are present, wsx scans the command for a known editor name and appends the appropriate goto arguments (after substituting any{path}first):code,codium,cursor,zed→--goto <file>:<line>vim,nvim,vi,nano,emacs,emacsclient→+<line> <file>
Detection matches the editor name anywhere in the command, so a terminal wrapper works transparently. For example, alacritty -e nvim is detected as nvim and becomes alacritty -e nvim +<line> <file>, opening the file at the changed line in a new terminal window.
wsx config set editor_cmd 'alacritty -e nvim'
Commands with {path} also work — the worktree is substituted first, then the editor is auto-detected or {file}/{line} are substituted:
wsx config set editor_cmd 'xdg-terminal-exec --dir={path} nvim'
For an editor wsx doesn't recognize, add {file} and {line} placeholders to control the exact syntax:
wsx config set editor_cmd 'myed --line {line} {file}'
Error visibility. If the editor fails to launch, wsx surfaces the error in a dismissible prompt — failures are no longer silent.
Schema and defaults
chronology_config is a JSON blob set globally via wsx config set or overridden per-repo via the repo settings modal (s on the dashboard, select the chronology_config row). Every field is optional; missing fields fall back to defaults.
| Field | Type | Default | Effect |
|---|---|---|---|
visible | bool | true | Master toggle. false hides the bar entirely (same as Ctrl-x c). |
side | "left" / "right" | "right" | Which side of the attach area the bar is docked to. |
width.percent | u8 | 32 | Target width as a percent of the attach area's columns. |
width.min_cols | u16 | 24 | Minimum width in columns. |
width.max_cols | u16 | 60 | Maximum width in columns. |
Setting the global value
wsx config set chronology_config '{"side":"left","width":{"min_cols":30}}'
wsx config get chronology_config
wsx config set chronology_config "" # clear (reverts to defaults)
Partial JSON is fine — unspecified fields inherit defaults. Malformed JSON is rejected with a non-zero exit and the previous value is preserved.
Per-repo override
Open the repo settings modal with s on the dashboard, select the chronology_config row, and press Enter. $EDITOR opens on {}\n (or the current override). Save to apply; press d to clear the override and fall back to the global value.
Example — pin the bar to the left for a repo with a wide main pane:
{ "side": "left", "width": { "percent": 28 } }
By default, wsx spawns Claude Code (claude) as the coding agent in every workspace. You can choose a different agent per-workspace or set a global default:
wsx config set coding_agent hermes # new workspaces use hermes by default
wsx workspace create backend --agent pi # override for a single workspace
Supported agents:
| Agent | CLI option | Source | Config |
|---|---|---|---|
claude (default) | --agent claude | claude binary (override via WSX_CLAUDE_BIN) | Environment + ~/.claude.json MCP |
pi | --agent pi | pi binary, @earendil-works/pi-coding-agent (override via WSX_PI_BIN) | ~/.pi/ |
hermes | --agent hermes | nousresearch/hermes-agent | ~/.hermes/config.yaml (provider, model) |
codex | --agent codex | codex binary (override via WSX_CODEX_BIN) | ~/.codex/config.toml |
omp | --agent omp | omp binary, oh-my-pi (override via WSX_OMP_BIN) | ~/.omp/agent/config.yml |
Hermes integration
When a workspace uses coding_agent: hermes, wsx spawns hermes (or the path in WSX_HERMES_BIN) instead of claude. Hermes runs in classic REPL mode and receives wsx custom instructions and auto-rename directives.
AGENTS.md management: Because Hermes lacks a --append-system-prompt flag, wsx injects instructions into a fenced block at the end of AGENTS.md in the worktree's working directory:
<!-- BEGIN wsx-managed -->
…injected instructions…
<!-- END wsx-managed -->
The block is rewritten every time Hermes spawns and automatically cleaned up when there's nothing to inject. This approach works whether or not the repository tracks AGENTS.md in git:
- Untracked
AGENTS.md: wsx adds it to.git/info/excludeso it doesn't show up ingit status. - Tracked
AGENTS.md: the worktree will show the file as modified during a Hermes spawn — this is expected and the modification disappears on subsequent spawns when there's no custom instructions to inject.
Session detection: On every Hermes spawn, wsx writes a timestamp marker at <worktree>/.git/info/wsx-hermes-spawn-at (per-worktree-local, never committed). To find the active Hermes session for a worktree, wsx queries ~/.hermes/state.db for the most recent session started at or after that timestamp (with a 2-second look-back buffer to absorb clock skew). This drives both the prior-session indicator on the dashboard and the --resume <id> flag on Continue spawns. Note: if two worktrees both spawn Hermes within a few seconds of each other, the lookup is best-effort — the more-recent session could be attributed to either worktree depending on timing.
Session-tail: wsx tails ~/.hermes/state.db (sqlite) to populate the dashboard's RECENT CHAT, SESSION SUMMARY, and last-message columns for Hermes workspaces. The following fields are populated: last assistant text, first user prompt, stop reason, tool-use counts, and per-event snapshots (user messages, assistant text, and tool calls — including ran \
Environment overrides: configure Hermes via ~/.hermes/config.yaml (persistent settings), or set WSX_HERMES_MODEL and WSX_HERMES_PROVIDER to override per-workspace:
WSX_HERMES_MODEL=llama-3-70b-instruct WSX_HERMES_PROVIDER=together wsx workspace create backend --agent hermes
Codex integration
When a workspace uses coding_agent: codex, wsx spawns codex (or the path in WSX_CODEX_BIN) instead of claude. Codex receives wsx custom instructions and auto-rename directives.
Instruction injection: Codex has no --append-system-prompt flag, so wsx passes the workspace doctrine, the auto-rename hint, and any custom instructions as a Codex config override on the spawn command line:
codex -c 'developer_instructions="…injected instructions…"' \
-c 'project_doc_fallback_filenames=["CLAUDE.md"]'
Codex renders developer_instructions as the first developer-role message, ahead of its own instructions and ahead of the user-role message that carries AGENTS.md. Nothing is written to your worktree — no AGENTS.md, no .git/info/exclude entry. A repo's own AGENTS.md is still read by Codex as usual, and project_doc_fallback_filenames makes Codex fall back to CLAUDE.md in repos that have no AGENTS.md.
Both overrides are applied only to fresh spawns. codex resume --last restores the session's stored configuration and ignores these two keys, so a resumed session keeps the doctrine it was started with. It also means edits to a workspace's custom instructions or related-repo context never reach an already-started Codex session — re-attaching with resume --last after editing them won't pick up the change, since only a fresh spawn re-composes the -c overrides. Requires Codex 0.146.0 or newer.
If a worktree was used with an older wsx, it may contain a wsx-created AGENTS.md; deleting it lets the new CLAUDE.md fallback work.
Claude slash commands: before each Codex spawn, wsx mirrors Markdown files from ~/.claude/commands/ into a local Codex plugin at ~/plugins/wsx-claude-commands/commands/ and registers that plugin in the implicit personal marketplace at ~/.agents/plugins/marketplace.json. The marketplace entry is marked INSTALLED_BY_DEFAULT, so commands such as /pull-request and /commit-changes are available in Codex without maintaining a second command set. Edits to the Claude command files are picked up on the next Codex spawn.
Spawn: fresh workspaces launch bare codex. Non-yolo sessions use Codex's built-in interactive approvals + workspace-write sandbox; --yolo workspaces add --dangerously-bypass-approvals-and-sandbox.
Continue: codex resume <thread-id> once the instance's thread id has been recorded from its notify payload (see Sessions survive a restart); before that, codex resume --last, which Codex filters to the current directory natively — the worktree's own most-recent session.
Activity: the dashboard detail bar tails the worktree's rollout file under ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl. RECENT FILES is not yet populated for Codex (file edits are inferred-via-shell and not tracked).
Model: set WSX_CODEX_MODEL to pass -m <model> to Codex (e.g. gpt-5.4). Unset = Codex default.
Pi integration
Session identity: wsx writes a small extension to its state dir
(pi-session-report.ts) and passes it as pi -e <file> on every spawn. On
each session_start (startup, /new, /resume, fork) it runs wsx status from-notify --agent pi with the session id, which is how a pi instance is
resumed exactly after a wsx restart — see Sessions survive a
restart. Nothing is
written to the worktree.
Oh My Pi integration
omp is oh-my-pi
(@oh-my-pi/pi-coding-agent). It is not the same harness as pi, which is
@earendil-works/pi-coding-agent. The two share ancestry — which is why they
write the same session-file format — but they are separately maintained, have
different CLIs, and can both be installed at once. --agent pi and --agent omp mean different binaries.
Spawn: fresh workspaces launch bare omp. Non-yolo sessions inherit
whatever tools.approvalMode you configured; --yolo workspaces add
--approval-mode yolo.
Continue: omp --resume=<file> once wsx has read the instance's session
file from omp's terminal breadcrumb (see Sessions survive a
restart); before that,
omp -c, which omp resolves against the session directory for the current
cwd — the worktree's own most-recent session, exact only while one omp agent
lives there.
Instructions: doctrine, the auto-rename directive, and a workspace's custom
instructions compose into a single --append-system-prompt. Related-repo paths
ride on --add-dir. omp is the only harness besides Claude that supports both
flags, so nothing is written into the worktree — no AGENTS.md block (unlike
Hermes) and no config overrides (unlike Codex).
Skills and slash commands ride on a config overlay. omp's Claude discovery
provider can load ~/.claude/skills/*/SKILL.md, ~/.claude/commands/*.md and
Claude marketplace plugins (superpowers, for example), but since omp 18 every
Claude user-level source is off by default: skills.enableClaudeUser and
commands.enableClaudeUser default to false (they were true in 17.x), and
the new enabledProviders list defaults to empty, which keeps the
claude-plugins source out too. Left alone, omp reports Unknown skill: wsx,
your pinned command chips do nothing, and plugin skills are missing. So before
every omp spawn wsx writes a small overlay to <wsx state dir>/omp-config.yml
(by default ~/.local/state/wsx/omp-config.yml; XDG_STATE_HOME relocates it)
that turns the two toggles on and sets enabledProviders: [claude-plugins],
then launches omp --config <that file>.
The overlay applies to that run only; your ~/.omp/agent/config.yml is never
edited. It does take precedence over your own config: an explicit false for
either toggle is overridden in wsx-spawned sessions, and because omp replaces
arrays rather than merging them, an enabledProviders list of your own is
replaced by [claude-plugins] for those sessions. It enables every skill and
command under ~/.claude, not only the ones wsx installs. It deliberately
lists claude-plugins rather than claude: the whole claude source would
also load your Claude hooks, MCP servers and ~/.claude/CLAUDE.md into omp. If
the overlay cannot be written (read-only state dir), wsx logs a warning and
launches omp without it. omp's other skill filters (skills.ignoredSkills,
skills.includeSkills) still apply. There is still no separate omp skills target for wsx setup install-skill — the Claude one covers it, for the same reason it covers Pi.
Session detection and activity: omp stores sessions at
~/.omp/agent/sessions/<encoded-cwd>/<ts>_<uuid>.jsonl, where the directory
name is the cwd with $HOME (or the temp root) stripped and / collapsed to
-. Because omp writes the same JSONL schema pi does, wsx reuses the pi parser,
so RECENT CHAT, SESSION SUMMARY, tool-use counts and the last-message column are
populated exactly as they are for Pi. Like Claude, Pi and Codex, omp indexes
sessions by worktree path, so it participates in the worktree-sessions snapshot
that stops a recycled workspace slug from resuming its predecessor's
conversation.
Status reporting: omp exposes pre/post tool hooks only — there is no
turn-lifecycle event (nothing equivalent to Claude's stop / prompt-submitted /
permission-prompt hooks, or Codex's notify) — so there is no deterministic
status wiring, the same position Pi and Hermes are in. Status still updates from
the agent itself calling wsx status set, and from the session-JSONL heuristic.
Claude and Codex remain the only harnesses with automatic harness-level status.
Environment overrides: configure omp via ~/.omp/agent/config.yml, or set
WSX_OMP_MODEL to override the model per-workspace:
WSX_OMP_MODEL=anthropic/claude-opus-5 wsx workspace create backend --agent omp
There is no WSX_OMP_PROVIDER: omp documents --provider as legacy and accepts
provider/id in --model, so WSX_OMP_MODEL covers both.
A workspace isn't limited to a single agent. You can attach additional agents — of the same kind or different kinds — to one workspace. Every agent runs as its own session but they all share the same git worktree and branch, and they can message each other. This is useful for, say, running a second Claude as a dedicated reviewer alongside the one doing the work, or pitting claude and codex at the same problem in the same tree.
Every workspace starts with exactly one agent — the primary, chosen at creation time by --agent or the coding_agent setting (see Coding agents). Everything below is about adding more on top of that.
Adding and removing agents
In the TUI, press Ctrl-x a while a workspace is selected to open the agents panel. It lists the agents already attached (the primary is tagged (primary)) and an "add" picker of the four kinds:
| Key | Action |
|---|---|
↑/↓ | Move through the add picker |
Enter | Add the highlighted kind |
a | Add one of every kind at once |
x | Remove the most-recently-added (non-primary) agent |
Esc | Close the panel |
Newly added agents spawn immediately with the workspace's context injected. The primary can't be removed from the panel — it lives for the life of the workspace.
Removing an agent with x closes the panel and drops that agent's pane from the attached view. If you were focused on it, focus moves to another pane in the same workspace; if it was the only pane, the view re-attaches to the workspace's primary. Either way you stay attached rather than being returned to the dashboard.
From the CLI, the equivalent of the panel's "add" is:
wsx agent add <kind> # kind = claude | pi | hermes | codex | omp
This runs against the current workspace — the one whose worktree you're in, or the one named by $WSX_WORKSPACE_ID (see identity below). It prints the new agent's label, e.g. added claude#2.
Sessions survive a restart
Quit wsx and come back, and each Claude, Codex, pi and omp agent in the workspace gets its own conversation back — not a blank chat, and not each other's (Hermes is the exception, see below). A harness's own "continue" flag can't do that: it resumes the most recent conversation in the directory, and once two agents share a worktree that is whichever one spoke last. So wsx tracks a session id per agent instance and respawns with it. How the id is obtained depends on the harness:
| Agent | Where the id comes from | Respawn |
|---|---|---|
claude | Reported by the status hooks wsx injects (every payload carries it; each hook runs with that instance's $WSX_AGENT_INSTANCE_ID). A /clear moves the recorded id along with it. | claude --resume <id> |
codex | The thread-id in the notify payload wsx already receives after each turn. | codex resume <id> |
pi | Minted by wsx at the instance's first spawn and passed as --session-id, which pi creates-or-resumes (pi prints a one-line "creating a new session with that id" notice on that first spawn). From then on pi reports every session start itself: wsx loads a small extension (pi -e <wsx state dir>/pi-session-report.ts) that calls back into wsx on session_start, so /new, /resume and forks all move the recorded id. A pi primary from before ids were tracked adopts its newest session on its next spawn, skipping any session a pi peer owns or a previous occupant of the path left behind. If another registered pi instance has no recorded session id, ownership is ambiguous and adoption is skipped; agents of other kinds do not block adoption. With ambiguous ownership or nothing eligible, the primary starts fresh with a new pin rather than pi's cwd-wide continue. | pi --session-id <id> |
omp | Read from omp's own per-terminal breadcrumb (~/.omp/agent/terminal-sessions/<pts-N>), which names the session file each terminal last opened. wsx created the terminal (or, in a shared workspace, asks tmux which terminal the pane is), so the file maps to exactly one instance. Device numbers get reused, so a crumb that already existed when wsx created the terminal is never taken for this agent's, however recent; under tmux, crumbs older than the tmux session are ignored instead. wsx polls every ~2s while the agent runs and once more on quit and share/unshare, so a /new is followed. | omp --resume=<file> |
hermes | Not available: the id only exists inside the Hermes process. | added agents start fresh |
Except for pi's adoption-or-fresh behaviour above, until an instance has an id — a workspace created before this was tracked, an agent that has not completed a turn yet (Codex) or written its session file yet (omp), or a hermes peer — the old behaviour applies: the primary falls back to its harness's cwd-wide continue, an added agent starts fresh with its handoff note.
An instance whose recorded session is no longer on disk starts fresh, primary or not, never with the cwd-wide continue: its own conversation is gone (deleted, or a /new that omp had not yet written out), so the directory's most recent session is by definition someone else's. Changing a workspace's agent kind clears the recorded identity, since a session belongs to one harness.
Known limits: the session roots are the harnesses' defaults (~/.claude/projects, ~/.codex/sessions, ~/.pi/agent/sessions, ~/.omp/agent); a non-default CODEX_HOME, CLAUDE_CONFIG_DIR or pi/omp session directory is not consulted. An omp /new followed by exiting omp within the same ~2s poll is not captured. The tmux lookup reads the session's active pane, so splitting the shared pane before wsx first looks can point it at the wrong terminal.
Switching focus between agents
When a workspace has more than one agent, the attached view's bottom row (the one with the pinned-command chips) gains a set of agent pills, right-justified ahead of the workspace stats, listing each agent with a single-letter switch key:
^x menu 1 pr 2 fb ──────── ● claude q ○ codex w ○ pi r opus 4.8 45k/200k ● 2p +12 −3 ⏺ #152 open
Press Ctrl-x then the key (q, w, r, …) to point the focused pane at that agent's session, or click the pill. Each pill opens with a dot in the agent's kind colour; the pill whose agent is in the focused pane carries a filled dot (●), the others a hollow one (○). The keys are drawn from a fixed pool — q w r y i o p s h j — assigned in display order (primary first). A workspace with more than ten agents renders the rest keyless; they stay clickable whenever the pills are shown. The pills only appear once a second agent exists; a single-agent workspace looks exactly as before. On a terminal too narrow for everything, the model/token stat is dropped first, then the pills as a whole group (never partially). The Ctrl-x switch keys keep working with the pills hidden; a keyless agent needs a wider terminal to be clicked.
Because agents share the worktree, switching focus is just changing which session your keystrokes go to — there's no branch-swapping or checkout involved.
The model and context-token usage follow the agent in the focused pane, including when focus moves between split panes. Each agent's recorded session is tracked independently. If its transcript or usage is unavailable—or an unrecorded session cannot be distinguished from another agent of the same kind—the usage chip is omitted rather than showing another agent's numbers.
Inter-agent messaging
Agents can send each other messages — a peer in the same workspace by default, or any agent in another workspace with --workspace:
wsx agent send [--workspace <repo>/<slug>] <label> <message…>
<label> is an agent's footer/list label (claude, claude#2, codex, …),
or the reserved label primary for the workspace's primary agent. The rest of
the line is the message body. Without --workspace the target is the current
workspace; with it, any workspace — which is how one agent hands a task to a
freshly created workspace's agent. Delivery is asynchronous: the message is
queued and injected into the target's session on the next tick, prefixed with a
banner so the recipient knows where it came from:
[message from claude#2]
…your message body…
A sender in a different workspace is qualified with its <repo>/<slug>, so
the recipient can see which workspace the work came from:
[message from workspacex/parent-task claude]
…your message body…
If the sender is the wsx CLI itself (not another agent — i.e. $WSX_AGENT_INSTANCE_ID is unset), the banner is just [message]. If the target agent isn't running yet, wsx spawns it first, then delivers. Sending to a label that doesn't exist in the target workspace errors with that workspace's agent labels listed inline (wsx agent list only reports the current workspace, so it can't describe another one).
Queued messages are injected by the running wsx TUI, so wsx agent send
warns on stderr when no dashboard is running — the message stays queued and is
delivered when one starts.
Delivery waits for the target agent to be ready to accept input: its TUI must
be up (not still booting) and its output quiet. A cold agent takes a second or
two to get there, and one that's midway through a turn takes as long as the
turn does — the message lands at its prompt rather than mid-work. A message is
only marked delivered once it has actually been written to the agent's
terminal; if the write doesn't happen, it stays queued and is retried. After
several failed attempts wsx stops retrying and the workspace's dashboard row
shows a red ✉! badge, so an undeliverable message is visible rather than
silently dropped. Restarting wsx clears the attempt counts and retries.
Mail queued while no dashboard was running is delivered when one starts. Only one message at a time is injected into any given agent — messages that arrive while a delivery is in flight wait their turn, so they can't interleave in the agent's terminal.
A message counts as delivered only when the terminal write for it is acknowledged, so a queued write that never reaches the agent is retried rather than recorded as sent.
Delivery is at-least-once across a crash: if wsx dies after writing a
message into an agent's terminal but before recording it as delivered, the
message is still queued and will be injected again when wsx restarts. The
in-flight bookkeeping that prevents duplicates is in memory, so it does not
survive the process.
Since all agents write to the same files, prefer messaging to hand off work rather than editing the same paths in parallel.
Listing agents
wsx agent list
Prints one agent per line — its instance id and label, with (primary) appended for the primary — for the current workspace:
1 claude (primary)
2 claude#2
4 codex
The leading number is the agent's instance id — the same value wsx injects as $WSX_AGENT_INSTANCE_ID into that agent's session.
Agent identity and labels
Each agent instance has a label derived from its kind and its ordinal within that kind: the first of a kind is the bare name (claude), and each subsequent one of the same kind gets a #N suffix (claude#2, claude#3). The same rule produces the labels shown in the footer row, in wsx agent list, and in message banners.
When wsx spawns an agent it injects two environment variables into that session, so the agent (or scripts it runs) can address the multi-agent CLI without guessing:
| Variable | Value |
|---|---|
WSX_WORKSPACE_ID | The workspace this agent belongs to |
WSX_AGENT_INSTANCE_ID | This specific agent instance |
wsx agent commands resolve the "current" workspace from $WSX_WORKSPACE_ID first, falling back to matching the current directory against known worktrees — so the commands work both from inside an agent session and from a plain shell in the worktree. wsx agent send uses $WSX_AGENT_INSTANCE_ID to stamp the [message from …] sender on outgoing messages.
--workspace <repo>/<slug> overrides that resolution for the target;
$WSX_AGENT_INSTANCE_ID still identifies the sender, which is how a
cross-workspace message gets its <repo>/<slug>-qualified banner.
Each repo can have a setup script (run when a workspace is created) and an archive script (run when a workspace is removed). Both are stored in the wsx state database and configured per-repo via the CLI:
wsx repo set-setup <repo-name> 'bun install'
wsx repo set-archive <repo-name> 'rm -rf node_modules'
For multi-line scripts, pass a file with the @ prefix or open $EDITOR:
wsx repo set-setup <repo-name> @./scripts/setup.sh
wsx repo edit-setup <repo-name>
wsx repo edit-archive <repo-name>
Each script is executed as $SHELL -ilc "$value" (interactive + login) with cwd set to the new worktree and two extra env vars: WSX_REPO_ROOT (the source repo) and WSX_WORKTREE (the new worktree). Running as a login + interactive shell means your ~/.zprofile and ~/.zshrc (or bash equivalents) are sourced first, so tools activated there — mise, direnv, asdf, aliases — are available to the script. If $SHELL is unset, empty, or points at a POSIX-only shell (sh, dash, ash) that doesn't support -l, wsx falls back to /bin/bash. Setup failure does not block the workspace from being usable; it's surfaced as a [setup-failed] badge on the dashboard. When you create a workspace from the dashboard, the script's output is captured to ~/.local/state/wsx/logs/setup-<repo>-<name>.log (overwritten on each run, and moved along with the workspace when you rename it). Press ? then o on the workspace to read it in the TUI — live while the workspace is still building, from the file afterwards — or open the file directly when a workspace shows [setup-failed]. Passing an empty value clears the script.
Editing in the TUI
Press s on any dashboard row to open the Repo settings modal for that
row's repo. The modal lists the per-repo fields:
namebranch_prefixbase_branchcustom_instructionssetup_scriptarchive_scriptpinned_commandsrelated_reposdetail_bar_config(see Workspace detail bar)chronology_config(see Change chronology)
When no repo value is set, branch_prefix, custom_instructions,
pinned_commands, and detail_bar_config preview the global value with
an (inherited) label, if one is configured. Fields without a configured
value show (unset). A blank or whitespace-only pinned_commands value
also inherits from global config.
The pinned_commands preview lists command labels, separated by commas.
If the labels do not all fit, +N more counts the commands not shown
(+N when space is especially tight).
A configured list with no valid commands shows (none).
↑/↓ selects a field. Press Enter to edit — wsx temporarily leaves
the TUI, opens $EDITOR (or vi if unset) on a tempfile prepopulated
with the repo-local value, and saves whatever you write when the editor
exits. Inherited previews are not copied into the editor. Press d to
clear the highlighted repo value, restoring inheritance where supported;
the global config is unchanged. Esc closes.
Repo custom instructions supplement global instructions rather than replacing them. Repo detail-bar settings merge with the global config.
The editor needs to be a terminal-native editor that returns when you
quit (vim, nvim, helix, micro, nano). GUI editors that return
immediately without a --wait flag will appear to "save nothing" —
keep $EDITOR pointed at a CLI editor for this flow.
Editor/terminal/diff hooks, the context digest for editor-hosted agents, remote access and control, MCP inheritance, and the bundled agent skill.
[e] and [t] on the dashboard launch your editor or terminal in the selected workspace's worktree directory. Both spawn detached so wsx keeps running.
Resolution chain (first non-empty wins):
- Editor:
editor_cmdsetting →$VISUAL→$EDITOR - Terminal:
terminal_cmdsetting →$TERMINAL
TUI editors (vim, nvim, helix, emacs -nw) need to be wrapped in a terminal command because the spawned editor has no controlling TTY of its own. Example:
wsx config set editor_cmd "alacritty -e nvim"
GUI editors (VS Code, Cursor, Zed) work directly:
wsx config set editor_cmd "code"
{path} placeholder
If your command contains {path}, the worktree path is substituted there
instead of being appended. Useful when the editor expects the path as a flag
value, or when launching a TUI editor inside a terminal where you want the
terminal's cwd to be set rather than passing the path to the editor:
wsx config set editor_cmd "xdg-terminal-exec --dir={path} nvim"
Result: xdg-terminal-exec --dir=/path/to/worktree nvim (nvim starts in the
worktree directory with no file argument — avoids triggering netrw / tree
plugins on a directory open).
For terminal commands the same substitution applies, though most terminals honor the spawned process's cwd already so you typically don't need it.
Diff command
[v] spawns the configured difftool with the selected workspace's worktree path as {path} and the repo's main branch as {base}. Unlike editor/terminal, there's no env-var fallback — set diff_cmd explicitly.
Examples (note the three dots — explained under "Why three dots?" below):
# Terminal pager with delta-prettified diff
wsx config set diff_cmd "alacritty -e sh -c 'cd {path} && git diff {base}...HEAD | delta'"
# Neovim with diffview.nvim (set alacritty's cwd so nvim doesn't open {path} as a buffer)
wsx config set diff_cmd "alacritty --working-directory={path} -e nvim -c 'DiffviewOpen {base}...HEAD'"
# VS Code (opens the workspace; user navigates to Source Control panel)
wsx config set diff_cmd "code {path}"
The base ref is auto-detected from origin/HEAD and substituted as the upstream tracking ref (e.g. origin/main) — using the upstream means a stale local main doesn't poison the diff. Falls back to main if your repo doesn't have origin/HEAD set. (Tip: git remote set-head origin --auto after cloning fixes that for the wsx repo metadata too.)
Why three dots? git diff A..B (two dots) lists every commit on B that isn't on A's current tip. If your local main is behind origin/main, those upstream commits show up as "extra changes" in your branch diff. A...B (three dots) anchors at the merge base — the commit where your branch diverged — so stale local refs don't pollute the view. This is what gh pr and most code-review tools use.
For editor_cmd and terminal_cmd, if neither the setting nor the env-var fallback is set, an error modal explains how to configure. diff_cmd has no env-var fallback and errors directly if unset.
Giving your editor's agent wsx context
If the editor you open has its own AI agent, see
Editor-hosted agent context for wsx context write, which renders the workspace's recap, status, peers, and the primary
agent's last message into a file that agent can read.
When you open a workspace's worktree in your editor and use an AI agent that lives there (magenta.nvim, Cursor, a VS Code extension), that agent has no idea what wsx knows: the workspace's goal, the status its primary agent last reported, which peer agents exist, or what the primary agent was just doing.
wsx context closes that gap with one markdown file the editor can hand to
its agent as context. Nothing on the wsx side knows which editor is reading
it; the contract below is all an integration needs, and the
neovim section is one worked example of it.
The contract
An editor integration has four parts. wsx provides the first two; the editor provides the rest.
1. The command. Run wsx context write with the worktree as the
current directory. It prints one absolute path and exits 0. Outside a wsx
worktree it exits non-zero with an error on stderr, so an integration can
be installed globally and stay inert elsewhere. No flags, no environment
variables required.
wsx context show # print the digest to stdout
wsx context write # write it to $XDG_STATE_HOME/wsx/context/<repo>/<workspace>.md and print the path
Both resolve the workspace from the current directory (or WSX_WORKSPACE_ID
when set). write replaces the file atomically, so a reader never sees a
partial digest, and creates it with user-only permissions. The path is keyed
on the workspace name, so it changes after a workspace rename; the old file
is left in place.
2. The file. A markdown digest with a fixed section order (see What the digest contains) that ends with an External instructions block addressed to the agent. The block is the behavioural contract: what the agent may do in a shared worktree and how it reports back. The file can quote the primary agent's last message verbatim; do not commit or share it.
3. The refresh policy. The editor decides when to rerun the command. Sensible triggers are editor start, window focus, and the agent's chat panel opening. The command is cheap (a handful of sqlite reads, three git commands, one transcript scan) but is not free, so debounce or guard against overlapping runs on rapid focus events.
4. The context mechanism. How the file reaches the agent is the editor's concern. An agent that re-reads tracked files before each request (magenta.nvim does) needs the path added once. An agent that snapshots a file when it is added needs it re-added after each refresh, or a rules file that tells it to read the path itself.
Reporting back goes through wsx agent send <primary label> "<summary>"
run from the worktree. Because an editor shell carries no
WSX_AGENT_INSTANCE_ID, the message reaches the primary agent with a bare
[message] banner. wsx-spawned agents are told to expect this (see
The other direction).
What the digest contains
In order:
- repo/workspace name, branch and base ref, worktree path
- attached agents, primary marked
(primary) - the last pushed status (
working — "message" (source, 4m ago)) - the recap (goal / state / next)
git log --oneline <base>..HEAD(up to 20)- an uncommitted-changes line, which appears whenever git status could be read, even when there are no commits ahead of base
- the primary agent's last assistant message, from its session transcript (Claude Code, Pi, Hermes, Codex, and oh-my-pi are all supported), capped at 2000 characters. When several agents of the same kind share the worktree, the most recently active transcript of that kind is used; wsx does not record which session belongs to which instance.
- an External instructions block
Optional sections with no data are omitted; the agents and status lines
always render, showing - when empty. Git and transcript problems never
fail the command; only an unresolvable workspace, a database read error,
or an unwritable file does.
External instructions
The digest ends with this block, addressed to the editor-hosted agent:
You are an editor-hosted agent working inside a wsx-managed worktree. The agents listed above share this branch and this working tree with you, and one of them (the primary) owns this workspace's status and recap.
- Before editing, run
git statusandgit diff; the primary agent may have changed files since this digest was written.- Keep edits small and scoped. Do not create branches, rename the workspace, or run
wsx status set/wsx recap set; those belong to the primary agent.- When you finish a change, or when you need a decision the primary agent should make, report it with:
wsx agent send <primary label> "<one-paragraph summary>"Run it from this worktree; the workspace is resolved from cwd.- This file is regenerated by
wsx context write; do not edit it.
The other direction
wsx-spawned agents get a matching doctrine clause (see
Coding agents): an editor-hosted agent
may share the worktree, it reads this digest, and its messages arrive
unlabelled. They are told to re-check git status before assuming the tree
is theirs. The bundled wsx skill repeats the same guidance.
Editor integrations
neovim + magenta.nvim
magenta.nvim re-reads every context file before each request and ignores repeat additions of the same path, so a file that wsx keeps fresh is live context. Add this to your neovim config:
-- wsx: keep the workspace context digest fresh and hand it to magenta.nvim
local worktrees = vim.fn.expand("~/.local/state/wsx/worktrees/")
local added_path = nil
local in_flight = false
local function magenta_sidebar_visible()
for _, win in ipairs(vim.api.nvim_list_wins()) do
local name = vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win))
if name:find("Magenta Input", 1, true) then return true end
end
return false
end
local function wsx_context()
if in_flight then return end
if not vim.startswith(vim.fn.getcwd(), worktrees) then return end
in_flight = true
vim.system({ "wsx", "context", "write" }, { text = true }, function(out)
in_flight = false
if out.code ~= 0 then return end
local path = vim.trim(out.stdout)
if path == "" or path == added_path then return end
vim.schedule(function()
if magenta_sidebar_visible() then
vim.cmd("Magenta context-files " .. vim.fn.fnameescape(path))
added_path = path
end
end)
end)
end
vim.api.nvim_create_autocmd({ "VimEnter", "FocusGained" }, { callback = wsx_context })
-- Attach when magenta's input buffer appears in a window: covers opening the
-- sidebar and starting a new thread, so no manual :WsxContext is needed.
vim.api.nvim_create_autocmd("BufWinEnter", {
callback = function(ev)
if vim.api.nvim_buf_get_name(ev.buf):find("Magenta Input", 1, true) then
added_path = nil
wsx_context()
end
end,
})
vim.api.nvim_create_user_command("WsxContext", function()
added_path = nil
wsx_context()
end, {})
How it maps onto the contract:
- Refresh policy: the file is rewritten on every
VimEnterandFocusGained, with an in-flight guard so rapid focus events do not overlap. The cwd check keeps it inert outside wsx worktrees. - Context mechanism: the path is added to magenta once per path, and
only when the sidebar is already open, because
:Magenta context-filesforce-opens the sidebar otherwise. TheBufWinEnterhook fires when magenta's input buffer appears, so opening the sidebar or starting a new thread (magenta context is per thread) attaches the digest without a focus round-trip. A changed path (workspace rename) is re-added automatically. Later rewrites reach the agent on their own, since magenta diffs tracked files before each request. :WsxContextforces a rewrite and re-add as a manual fallback.
If $XDG_STATE_HOME is set, change the worktrees path to match.
Other editors
Any editor agent that can read a file on disk can use the digest. To add one:
- Run
wsx context writeon the editor's start and focus events (and when its chat panel opens), guarded against overlap. - Ignore a non-zero exit; that is the "not a wsx worktree" signal.
- Hand the printed path to the agent through whatever context mechanism the editor has: a context-file API, a rules file that instructs the agent to read the path, or re-adding the file after each refresh if the agent snapshots it.
- Leave the External instructions block to do the rest; it already tells the agent how to report back.
Nothing in the digest is editor-specific, and a second integration needs no change on the wsx side. Contributions of worked examples for other editors are welcome as sibling sections here.
Running wsx on one machine (e.g. your desktop) and attaching from another (e.g. a laptop) works cleanly with tmux + ssh — no wsx-specific networking required.
On the host machine:
tmux new -As wsx 'wsx'
This starts wsx inside a tmux session named wsx (or reattaches to it if one already exists).
From any other machine:
ssh desktop -t tmux attach -t wsx
Workspaces — and the claude sessions running inside them — keep running while you're detached, so picking up where you left off from a different machine just works.
Notes:
- wsx's leader key is
Ctrl-x, chosen specifically to not collide with tmux's defaultCtrl-bprefix (or anyone'sCtrl-acustomization). No tmux config needed. - Mosh drops in cleanly if your network is flaky:
mosh desktop -- tmux attach -t wsx. - Tailscale (or any VPN) makes the host reachable from anywhere by a stable name without port-forwarding.
Saving the invocation: once you've settled on a working ssh … tmux attach … command, save it as a named remote so reconnecting is just wsx remote <name>. See Named remote shortcuts.
This page covers running the whole wsx TUI over ssh + tmux. For per-workspace sharing — an individual agent session that survives wsx quitting and can be attached to directly, independent of wsx itself — see Shared workspaces. For browsing and attaching to shared workspaces on a remote host directly from wsx's dashboard (press H), see the "Browsing another machine" section in Shared workspaces.
A shared workspace runs its agent inside a tmux new-session -A instead of a plain PTY child. The agent lives in the tmux server, not in wsx's process tree — quitting wsx (or losing your ssh connection) doesn't kill it. Next time wsx starts, it reattaches to the same tmux session automatically.
Shared workspaces require tmux ≥ 3.2 (for the -e flag on new-session, used to forward wsx's environment into a pre-existing tmux server).
Create shared:
wsx workspace create <repo> --shared
Or from the dashboard, press S (capital) instead of n/N — it opens the same "new workspace" modal, just pre-set to shared. Ctrl-s toggles the shared flag while the modal is open, so you can flip it either way before confirming.
Convert an existing workspace:
wsx workspace share <repo> <slug>
wsx workspace unshare <repo> <slug>
These CLI commands flip the shared flag. Running sessions keep their current backend (shared or non-shared) until restarted manually — the command prints a note saying so. New or restarted sessions will pick up the new backend.
Alternatively, press T (capital) on a selected workspace row to open a confirmation modal. This immediately restarts any currently-running agent sessions in that workspace — there's no way to move a live process in or out of tmux — but conversation history isn't lost: the restart resumes via --continue, so the agent picks the conversation back up. Non-running instances just flip the flag with nothing to restart. T is a no-op on a repo header; sharing is per-workspace.
Session naming:
Each agent instance in a shared workspace gets a deterministic tmux session name: wsx-<repo>-<workspace> for the primary agent, or wsx-<repo>-<workspace>-<agent><ordinal> for additional instances (e.g. wsx-myrepo-fix-bug-codex2). Characters outside [A-Za-z0-9_-] in the repo/workspace name are replaced with -, since tmux rejects . and : in session names. If two workspaces sanitize to the same name (e.g. repo a + workspace b-c vs repo a-b + workspace c), wsx appends the workspace id to disambiguate, so -A never attaches to the wrong agent. The name is derived once, stored in session_ref, and reused verbatim afterwards — it is never re-derived, so renaming a workspace does not orphan its running agent.
Dashboard indicator:
Shared workspaces are marked on the dashboard with a badge just left of the branch name — the tmux logo when nerd fonts are enabled, a hollow ◇ otherwise. The badge is green while the workspace's tmux session is alive (attached in this wsx, or detached-but-running on the server) and red when the workspace is shared but no live session backs it — the session has exited or was never started, so a remote peer can't attach. Direct workspaces show no badge.
A shared workspace's tmux session can outlive the wsx process that spawned it — right after a wsx restart, the agent is still running on the server even though this wsx holds no client for it. The green badge covers that case too: wsx periodically confirms the session with tmux has-session, so the badge stays green while the workspace's row status reads idle. Attaching (Enter on the row) reattaches wsx's client to the live session; the agent and its history are exactly where you left them.
Manual access:
Because the agent is a normal tmux session, you can attach to it directly, bypassing wsx entirely:
tmux attach -t wsx-<repo>-<workspace>
This works over a plain ssh connection today — no wsx-specific networking, remote-control setup, or port-forwarding required. See Remote access for the broader pattern of running wsx itself over ssh/tmux; shared workspaces are the finer-grained, per-workspace version of the same idea; you can tmux attach to one agent's session without pulling in the rest of wsx.
Listing shared workspaces:
wsx shared list
wsx shared list --json
Without --json, prints one tab-separated line per agent instance: repo, workspace, tmux session name, and alive/(dead)/-. With --json, prints the same data as structured records (repo, workspace, branch, worktree path, and each agent's label/kind/session name/liveness) — useful for scripting against.
Browsing another machine:
To browse and attach to shared workspaces running on a remote machine, configure a list of ssh destinations under wsx config edit shared_hosts. The setting stores one entry per line as name=ssh-destination, e.g.:
mini=eben@ebenmini.local
lab=user@lab.example.com
On the dashboard, press H (capital, mnemonic hosts) to open a picker over these configured hosts, sorted by name. If no hosts are configured, an error modal points you at wsx config edit shared_hosts.
Selecting a host spawns a background fetch via ssh <dest> "sh -lc 'wsx shared list --json'" (one pre-quoted remote command, so ssh's argv join preserves it; login shell so wsx is found on the host's PATH). Results render as a list titled "shared workspaces on <host>", showing one row per agent instance:
repo/workspace branch label ●|✗
The marker (● for alive, ✗ for dead/stale) indicates whether the remote tmux session still exists. Navigate with j/k (or ↑/↓), select a live row with Enter to attach, r to re-fetch the list, and Esc to close. The list is ephemeral — nothing is written to the local database, so there's no sync or cache-invalidation problem.
Attaching spawns ssh -t <dest> -- "sh -lc \"tmux -u attach -t '=<name>'\"" as a PTY session — the remote command is one pre-quoted argument routed through a login sh (the same PATH rules as the list fetch; sshd otherwise hands the command to a non-login zsh that reads only ~/.zshenv, where homebrew's tmux often isn't on PATH), the = target is single-quoted so zsh can't expand it as a command path, and -u forces UTF-8 (the ssh context has no locale, and without it tmux degrades box-drawing characters to rows of literal qs). You interact with the remote agent as if it were local; the exact-match = prefix ensures the correct agent is targeted, even if multiple agents sanitize to similar names.
Detaching and persistence:
Ctrl-x d detaches from the remote session, severing only the local ssh client. The remote agent keeps running in its tmux server — quitting wsx has the same effect. Reattaching resumes the exact session with its full history intact. Detaching lands back on the dashboard; the fetched list is ephemeral and never persisted, and pressing H again reopens the host picker with a fresh fetch.
Failure modes:
Fetching fails if the host is unreachable, ssh authentication fails, wsx is missing on the host's login-shell PATH, or a row's tmux session has since died (stale). All fetch errors surface in an error modal carrying ssh's stderr; dead rows show the ✗ marker and cannot be attached to (attempting to attach shows a notice "no live session to attach to").
Requirements:
- SSH key access to the remote host (password prompts are not supported for the background list fetch — use key-based auth via
ssh-agentor key files; the attach itself runs in a real terminal but key auth is strongly recommended for a smooth flow). - wsx and tmux installed on the host and reachable via a login
sh's PATH (e.g.,ssh <host> "sh -lc 'which wsx tmux'"should print both — the outer double quotes keepsh -lc '…'a single argument, so ssh's space-join back into the host shell preserves the inner quoting). macOS gotcha: PATH additions that live only in zsh config (~/.zshrc/~/.zprofile, including homebrew'sbrew shellenv) are invisible tosh -l— add them to~/.profiletoo. - Workspaces created as shared on the host (either via
wsx workspace create <repo> --sharedor by converting an existing one withT). - A local
sshbinary (no local tmux needed for remote attach).
v1 limitation — scrollback:
Reattaching (in wsx or via a bare tmux attach) only repaints the tmux session's current visible screen — wsx's own scrollback buffer (see Mouse, scrollback, and text selection) resets with each new client and doesn't carry history across a detach/reattach. tmux's own scrollback for the session is unaffected and still reachable in-session via its usual copy-mode (Ctrl-b [ with tmux's default prefix). A richer remote-scrollback view is planned for a later phase.
Claude Code's --remote-control flag exposes a running session to
claude.ai/code and the Claude iOS/Android
apps. The local PTY behavior is unchanged — claude prints a session
URL and a QR code at startup that you can scan from your phone or
open in a browser to attach remotely.
wsx passes --remote-control to every claude spawn by default, so
any session is reachable from your phone without extra setup.
Toggle: disable with wsx config set remote_control false. With
it off, sessions are local-only and nothing is sent to Anthropic's
relay servers.
Sandbox: claude offers --sandbox as an extra safety wrapper for
remote-issued commands. Disabled by default in wsx; enable with
wsx config set remote_control_sandbox true.
Auth: the relay rides on your claude.ai account. If you're not signed in or you're offline, the local session continues to work and the remote relay just fails silently.
Privacy: enabling remote control routes session state through Anthropic's relay infrastructure. The session URL emitted in the PTY is also visible to anyone seeing your screen.
wsx remote # list configured names (alphabetized), one per line
wsx remote <name> # exec the stored command — process-replaces wsx
wsx config edit remotes # opens $EDITOR on the blob
Stores frequently-used remote shell commands — typically ssh -t host '…tmux attach…' for reattaching a wsx session running on another machine (see Remote access) — under short names. The value is an arbitrary shell command run through sh -c, so nested quoting works as you'd type it at a terminal.
The remotes setting is a newline-separated blob, one name=command per line. There is no wsx remote add — wsx config edit remotes opens the existing blob in $EDITOR, and you add a remote by appending a new line. Clearing the buffer and typing only the new line replaces every other remote, so always keep the existing lines unless you mean to drop them. Example:
ebenmini=ssh -4 -t ebenmini.local "zsh -lc 'tmux attach'"
gpu=ssh gpu-box -t 'tmux -u attach -t main || tmux -u new -s main'
Parser rules: only the first = separates name from command (so = inside the command, e.g. an inline env-var, is preserved); whitespace around = is trimmed; blank lines are skipped; lines with an empty name or command are dropped; duplicate names take the last value.
wsx remote <name> exec-replaces the wsx process with sh -c <command>, so signals and TTY state flow straight through to the remote session; when it exits you're back at your local shell with no wsx parent process. Unknown names error out with the list of available names.
Claude Code stores MCP server config in ~/.claude.json under
projects.<absolute_cwd_path>.mcpServers. The lookup is keyed on the
literal cwd path at launch time. Because wsx launches claude inside a
worktree path (under ~/.local/state/wsx/worktrees/...), the source
repo's MCP servers aren't visible by default — claude looks up the
worktree path, finds no entry, and runs without those servers.
wsx mirrors the source repo's mcpServers into the worktree's project
entry every time a workspace session spawns. New servers added to the
source repo via claude mcp add ... show up in workspaces on the next
attach.
On wsx workspace archive, wsx removes the worktree's
projects[<worktree_path>] entry from ~/.claude.json to keep it
tidy.
Secrets: MCP server configs frequently include API tokens and other credentials. Mirroring copies them verbatim into the worktree entry. This is the same file with the same permissions, but it does mean the same secret is now keyed under two paths.
Toggle: this behavior is on by default. Disable it with:
wsx config set mcp_mirror false
With it disabled, wsx never reads or writes ~/.claude.json. You can
still configure MCP servers per-workspace by running claude mcp add ... while attached.
When you work across multiple repos that need to know about each other (a backend, a frontend, a marketing site), declare related repos per primary repo:
wsx repo set-related-repos backend frontend,marketing
When you spawn a workspace in backend, wsx invokes claude with --add-dir pointing at each related repo's source path. Claude can read, grep, and reference files in those directories freely.
To prevent claude from accidentally editing files in the source paths of related repos (which would land changes on whatever branch the source is on), wsx also appends a system-prompt instruction telling claude:
- Treat those directories as read-only.
- If changes are needed there, drive
wsx workspace create <other-repo> --name <slug>from this session, then hand the task to that workspace's own agent withwsx agent send --workspace <other-repo>/<slug> primary "<brief>"— do notcdinto the sibling worktree and make the changes yourself. Tell the user which workspace now owns the sibling task. Each repo gets its own branch and PR; cross-link them and merge in dependency order.
This is a soft guard, not a tool-level lock — it relies on claude following the instruction. The same trust model as custom_instructions. Installing the bundled wsx skill (wsx setup install-skill, see Agent skill) reinforces this with the full CLI vocabulary and slug-naming rules.
Unknown names in the list (e.g. a repo you renamed or unregistered) are logged and skipped at spawn time; the spawn still proceeds with the recognized names.
wsx setup install-skill
Writes the bundled skills to each detected agent's skills directory — ~/.claude/skills/<skill>/SKILL.md and the equivalent under ~/.codex / ~/.hermes. Claude is always targeted; Codex and Hermes are added when detected. The skills are embedded in the binary at compile time, so installing wsx on a new machine is cargo install then wsx setup install-skill.
Codex is considered installed when WSX_CODEX_BIN is set, codex is on PATH, or ~/.codex already exists; Hermes likewise via WSX_HERMES_BIN, hermes on PATH, or ~/.hermes.
There is intentionally no separate target for pi or omp. Both read skills from ~/.claude/skills — omp via its Claude discovery provider, which loads ~/.claude/skills/*/SKILL.md (and ~/.claude/commands/*.md as slash commands) — so the Claude target already covers them. omp 18 turned that user-level scan off by default, so wsx passes a config overlay on every omp spawn to turn it back on; see Coding agents.
Idempotent: re-running when an installed copy already matches reports "already up to date" without writing. If an installed copy has drifted (you edited it locally, or you're upgrading wsx with skill changes), it's overwritten and reports "updated".
Bundled skills
wsx setup install-skill installs every bundled skill for each detected agent:
wsx— drives the wsx CLI (workspace ops, slug-vs-branch_prefixnaming, cross-repo orchestration).agent-review— run inside a workspace to spin up a peer review agent. It takes the reviewer kind (claude|pi|hermes|codex|omp; asks when omitted), spawns it withwsx agent add, hands it the branch diff vsmain, and has it report a risk assessment + gap analysis back viawsx agent send.handoff— run inside a finished workspace (PR merged, work wrapping up) to continue the same feature or epic in a fresh workspace. The agent asks what the new workspace should implement (or takes it as the argument:/handoff add a --json flag), creates a same-repo workspace withwsx workspace create <repo> --name <slug>, and briefs its primary agent with a distilled summary of the session — decisions and why, rejected approaches, gotchas, file:line pointers, follow-ups — plus the request as its task. Replydeferto have the new agent wait for your request instead. The outgoing agent sets itselfdoneand does not archive the old workspace.
Pin either skill to a chip so it is one click away — add a line to your pinned commands. Use wsx config edit pinned_commands to append without clobbering existing chips (wsx config set replaces the whole value):
agent-review=/agent-review ...
handoff=/handoff
The agent-review line ends in ..., so the chip types /agent-review and waits for you to add the reviewer kind (codex, omp, …) before pressing enter; press enter with nothing and the skill asks which kind to spawn. Pin it as a plain /agent-review if you'd rather always be asked. The handoff chip runs /handoff with no request, so the agent asks for one before creating the workspace — the question is the chip's way of taking input.
In an omp session, a chip naming a bundled skill is sent as /skill:<name> (/skill:handoff, /skill:agent-review): omp only exposes skills under that prefix, and its own builtin /handoff — which summarizes and compacts the session in place — would otherwise catch the chip. Pin the bare /handoff form; wsx does the rewrite. Typing /handoff by hand in omp still reaches omp's builtin.
Run wsx --help for the full command list, or wsx <command> --help (e.g. wsx agent --help) for a group's commands and arguments. wsx --version prints the version.
wsx
Running with no arguments opens the dashboard.
wsx repo add <path> [--name <name>] [--prefix <prefix>]
Registers a git repository. <path> must be an existing git working tree.
--name <name>— display name on the dashboard. Defaults to the directory basename.--prefix <prefix>— per-repo branch prefix override. Usually omit this and use the globalbranch_prefixsetting instead. Setting both means the per-repo value wins.
Where a set-* command below takes a value, it accepts @/path/to/file to load that value from a file and "" to clear it (clearing falls back to the global setting where one exists).
wsx repo list
Lists registered repos with their paths.
wsx repo remove <name>
Removes a repo from the wsx registry. Does not delete the git repository on disk. Workspaces under the removed repo are also unregistered (but their worktrees remain on disk).
wsx repo set-name <name> <new-name>
Renames the repo in the wsx registry. The new name appears on the dashboard and is used in workspace references (e.g. wsx workspace create <repo>). Other commands like wsx repo set-prefix <new-name> ... must use the new name afterwards.
wsx repo set-path <name> <path>
Repoints the repo at a different source checkout, for when the repository moved on disk (a rename, or a project folded into a monorepo). <path> must be an existing git working tree, exactly as for repo add, and is stored absolute. The registry row keeps its name, prefix, scripts, instructions and workspaces, so this is the alternative to remove + add when you want that configuration to survive. Existing worktrees were created from the old checkout, so if the old path is gone they need re-creating; new workspaces are cut from the new path.
wsx repo set-prefix <name> <prefix>
Sets or changes the per-repo branch prefix override.
wsx repo set-instructions <name> <value-or-@file>
Sets per-repo custom instructions appended to claude's system prompt for sessions in this repo.
wsx repo set-pinned-commands <name> <value-or-@file>
wsx repo edit-pinned-commands <name>
Per-repo override of pinned_commands. Clearing falls back to the global setting.
wsx repo set-related-repos <name> <value-or-@file>
wsx repo edit-related-repos <name>
Per-repo list of other wsx-registered repos that workspaces in this repo should reference. Comma-separated names (e.g. frontend,marketing). At spawn time wsx looks each name up in the repo registry and passes --add-dir <source-path> to claude. Unknown names are silently skipped (logged at warn level — visible with RUST_LOG=wsx=warn or any less-specific filter).
wsx workspace create <repo> [--name <slug>] [--yolo] [--agent claude|pi|hermes|codex|omp] [--prompt <text>]
Creates a workspace in <repo>, equivalent to the dashboard's [n] keybind. <slug> is a kebab-case workspace name; the resulting git branch is <branch_prefix>/<slug>. When --name is omitted, an adjective-noun slug like merry-birch is generated. --yolo skips the permission prompts in the spawned agent session. --agent overrides the coding_agent setting (see Coding agents) for this workspace; when omitted, the setting applies (claude unless configured otherwise).
--prompt seeds the new workspace's agent with a starting task, equivalent to running wsx agent send against it immediately afterward. Like any queued message it is delivered by the dashboard, which spawns the agent on demand — so a workspace created with --prompt while no dashboard is running stays idle, and the command says so on stderr.
When create runs from inside a workspace — an agent handing work off, or a shell in a worktree — the new workspace inherits that workspace's yolo mode and agent kind: yolo is on if --yolo is passed or the parent is yolo, and the agent is --agent if passed, else the parent's agent, else the coding_agent setting. The command prints what it inherited and from where. Creates from outside any workspace fall back to the flags and settings alone.
Starting work from a phone
--prompt exists so a whole workspace can be started over SSH in one line, with the prompt as the only thing typed:
wsx workspace create backend --prompt "Fix the flaky input PTY tests"
Omitting --name is deliberate here: the workspace gets a placeholder slug, and the agent renames both it and the git branch from the prompt on its first turn (see Auto-rename modes).
Two existing behaviors make this self-sufficient. Claude sessions are spawned with --remote-control by default (see Remote control), so the new session appears in the Claude app without any URL to copy off the terminal. And agent sessions run under tmux, so the session survives your SSH connection dropping — back at a real terminal, the dashboard reattaches to it with full scrollback.
This assumes a wsx dashboard is already running on the target machine, since nothing else delivers the prompt. Leaving wsx running in a tmux session is the usual arrangement.
wsx workspace list [<repo>]
Lists workspaces as tab-separated repo<TAB>slug<TAB>branch<TAB>worktree_path rows. Pass a repo name to filter.
wsx workspace path <repo> <slug>
Prints just the worktree path. Designed for cd "$(wsx workspace path backend my-slug)".
wsx workspace rename <repo> <old-slug> <new-slug>
Renames the workspace slug AND its git branch in sync with the wsx database. Using git branch -m directly leaves wsx's DB stale.
wsx workspace archive <repo> <slug> [--keep-worktree] [--force-delete-branch]
Equivalent to the dashboard's archive action: stops any tracked processes running under the worktree unless --keep-worktree is given (SIGTERM, a two-second grace period, then SIGKILL; best-effort — see Process tracking), runs the per-repo archive script, removes the worktree (unless --keep-worktree), deletes the branch (force if --force-delete-branch), and drops the workspace from the registry.
A few command families live in their feature sections rather than here:
wsx agent list | send | add— see Multi-agent workspaceswsx config get | set | list | edit <key>— see Global settingswsx remote [<name>]— see Named remote shortcutswsx repo set-setup | set-archive | edit-setup | edit-archive <repo>— see Per-repo setup scriptswsx setup install-skill— see Agent skill
Environment variables and on-disk storage/configuration locations.
| Variable | Purpose |
|---|---|
WSX_RENAME_MODE | Auto-rename mode: claude (default) / local / off |
WSX_CLAUDE_BIN | Path to the claude binary (default: looked up via PATH). Used by tests to substitute cat. |
WSX_HERMES_BIN | Path to the hermes binary (default: looked up via PATH). Only used when coding_agent is hermes. |
WSX_HERMES_MODEL | Model override for Hermes, passed as HERMES_INFERENCE_MODEL env var on the child Hermes process. When set, overrides the model in ~/.hermes/config.yaml. |
WSX_HERMES_PROVIDER | Provider override for Hermes, passed as --provider to the Hermes CLI. Note: in classic REPL mode (the default), Hermes uses the persistent provider from ~/.hermes/config.yaml; this flag primarily affects -z/--oneshot and --tui modes. |
WSX_OMP_BIN | Path to the omp (oh-my-pi) binary (default: looked up via PATH). Only used when coding_agent is omp. Distinct from WSX_PI_BIN: pi and omp are different harnesses. |
WSX_OMP_MODEL | Model override for omp, passed as --model. Accepts omp's fuzzy patterns (opus, gpt-5.2) or a qualified provider/id. There is deliberately no WSX_OMP_PROVIDER — omp treats --provider as legacy. |
WSX_CODEX_BIN | Path to the codex binary (default: codex on PATH). Only used when coding_agent is codex. |
WSX_CODEX_MODEL | Model passed to Codex as -m (e.g. gpt-5.4). Unset = Codex default. |
WSX_WORKSPACE_ID | Injected into each agent session: the workspace it belongs to. wsx agent commands read it to resolve the current workspace. See Multi-agent workspaces. |
WSX_AGENT_INSTANCE_ID | Injected into each agent session: that specific agent instance. wsx agent send reads it to stamp the message sender. See Multi-agent workspaces. |
EDITOR | Editor invoked by wsx config edit (default: vi) |
VISUAL / EDITOR | Fallback when editor_cmd is unset |
TERMINAL | Fallback when terminal_cmd is unset |
XDG_STATE_HOME | Base for the wsx state directory (default: ~/.local/state) |
RUST_LOG | tracing filter (default: info); set wsx=debug for verbose logs |
HOME | Fallback for resolving the state directory |
| Path | Contents |
|---|---|
~/.config/wsx/theme.toml (honors XDG_CONFIG_HOME) | Optional bar theme file; see Themes. Reloaded while running. |
$XDG_STATE_HOME/wsx/state.db | SQLite database: repos, workspaces, settings |
$XDG_STATE_HOME/wsx/worktrees/<repo>/<workspace>/ | Worktree directories created by wsx |
$XDG_STATE_HOME/wsx/context/<repo>/<workspace>.md | Workspace context digest written by wsx context write for editor-hosted agents |
$XDG_STATE_HOME/wsx/logs/wsx.log | Daily-rotated tracing logs |
~/.claude/projects/<encoded-cwd>/<session>.jsonl | Claude Code's own session files (wsx probes these to detect resumable workspaces) |
Building, testing, and contributing to wsx.
cargo test -- --test-threads=1
The test suite substitutes claude with cat via WSX_CLAUDE_BIN, so it runs without Claude Code installed. --test-threads=1 is required because several tests mutate WSX_CLAUDE_BIN and HOME.
Releasing
A release is driven by a git tag. Everything after the tag is automatic.
Cut a release
-
Set the new version in
Cargo.tomland runcargo checksoCargo.lockpicks it up. Commit both files. -
Tag the commit and push the tag:
git tag v0.2.0 git push origin v0.2.0
The tag must match the version in Cargo.toml. The Release workflow compares
them and stops if they disagree, because a mismatch would ship binaries whose
wsx --version contradicts the release name.
What the workflow does
Pushing the tag runs four jobs in order: test, build, release, then
homebrew.
The test job runs the same commands as ci.yml on Linux and macOS. It is
here because ci.yml runs only on pushes to main and on pull requests, so a
tag push starts no tests of its own. Nothing is published if it fails.
The build job compiles four targets and packages each one as
wsx-<version>-<target>.tar.gz with a matching .sha256 file:
| Runner | Target |
|---|---|
macos-latest | aarch64-apple-darwin |
macos-latest | x86_64-apple-darwin |
ubuntu-22.04 | x86_64-unknown-linux-gnu |
ubuntu-22.04-arm | aarch64-unknown-linux-gnu |
Both macOS targets build on the same ARM runner. The Apple SDK is universal,
so the bundled SQLite in rusqlite cross-compiles for x86-64 from there. This
avoids the deprecated Intel runners.
Linux binaries are built on 22.04 rather than the current ubuntu-latest, so
they link against glibc 2.35 and stay usable on older distributions.
The packaging step reads each binary with file and fails if the
architecture does not match the target it was built for. This means a change
to the architecture a runner label points at stops the release instead of
shipping the wrong artifact.
The release job creates the GitHub release and uploads every tarball. The
homebrew job then rewrites Formula/wsx.rb and opens a pull request with
the new version and checksums.
Rebuild an existing tag
Run the Release workflow by hand from the Actions tab and give it the tag name. It replaces the assets on the existing release instead of failing.
Update the formula by hand
The Homebrew job calls a script you can also run locally. Download the release
tarballs and their .sha256 files into a directory, then:
scripts/update-homebrew-formula.sh 0.2.0 ./dist
The script only touches the version, the urls, and the checksums. Caveats, dependencies, and the test block survive a bump.
Nix
nix/package.nix pins its own version and the hash of the sessionx git
dependency. Bump the version there in the same commit as Cargo.toml. If the
sessionx revision in Cargo.toml changes, refresh the hash:
nix-prefetch-git --url https://github.com/bakedbean/sessionx --rev <rev>
Copy the hash field into outputHashes in nix/package.nix.
crates.io
wsx is not published on crates.io. Two things block it:
publish = falseinCargo.toml.- The
sessionxgit dependency. crates.io rejects a crate that depends on a git revision, sosessionxhas to be published first.
Once both are resolved, cargo binstall wsx starts working without the
--git flag, and the binstall metadata already in Cargo.toml needs no
change.