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.

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 with coding_agent or 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 remote CLI.
  • Pinned commands: define your /pull-request, /feedback, /ultrareview shortcuts once; fire them with Ctrl-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), and handoff (continue a finished workspace's epic in a fresh one, briefed with a summary of the old session). Installed with wsx 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-dir for 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 wsx stops 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:

PlatformTarget
macOS, Apple siliconaarch64-apple-darwin
macOS, Intelx86_64-apple-darwin
Linux, ARM64aarch64-unknown-linux-gnu
Linux, x86-64x86_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 local main doesn't pollute the view — the same diff gh pr shows.

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

KeyAction
Up / Down (or k / j)Move selection through repo headers and workspaces
h / lFold / unfold the focused repo (idempotent; use zz to toggle)
enter (or i) on a workspaceAttach to its claude session (spawns or resumes)
enter (or i) on a repo headerOpen the New Workspace modal targeting that repo
nNew workspace in the selected row's repo
Shift + NNew workspace in permissive mode (claude launches with --dangerously-skip-permissions)
eOpen the selected workspace in your editor (no-op on repo header)
tOpen the selected workspace in a terminal (no-op on repo header)
vView diff of the selected workspace's branch vs the repo's base branch (auto-detected; no-op on repo header)
Shift + KOn a workspace: show processes under its worktree. On a repo header: move the repo up one slot (persisted)
Shift + JOn a repo header: move the repo down one slot (persisted). No-op on a workspace
sOpen repo settings modal for the selected repo (or the parent repo when a workspace is selected)
dArchive the selected workspace (no-op on repo header)
oCycle how workspaces are ordered inside a repo: by recency (default) or by status. Persisted.
qQuit (kills all running sessions)
pToggle the project-manager digest pane (opens focused, instant, no agent session)
TabSwap focus between dashboard and the digest pane (when visible)
z zToggle fold on the focused repo
z aExpand all repos (override default-fold heuristic)
z MFold 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

KeyAction
enterConfirm
escCancel
y / nConfirm/cancel on ConfirmArchive
Printable chars / backspaceEdit the name field on NewWorkspace

Workspace actions card (? on a workspace)

KeyAction
rRename the workspace (and its git branch)
CPick a name color for its dashboard row
oOpen its setup log — see below
xCancel an in-flight setup (creates only; archive is not cancellable)
? / escClose 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.

KeyAction
Up / Down (k/j)Scroll one line
PageUp / PageDownScroll ten lines
g / HomeJump to the start of the log
G / EndJump back to the end
esc / enterClose (background work keeps running)

Attached workspace

Keystrokes are forwarded to the running claude session, except:

KeyAction
Ctrl-x dClose the focused pane. When only one pane is open, detaches back to the dashboard (session keeps running).
Ctrl-x Shift-DSave the current split layout for this workspace, then detach to the dashboard. Restored on next attach.
Ctrl-x EscDismiss 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 uOpen 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 aOpen the agents panel to add/remove agents in this workspace (see Multi-agent workspaces)
Ctrl-x eOpen the attached workspace in your editor (same editor_cmd as [e] on the dashboard)
Ctrl-x tOpen the attached workspace in a terminal (same terminal_cmd as [t])
Ctrl-x vView diff of the attached workspace's branch vs the base branch (same diff_cmd as [v])
Ctrl-x kShow 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 xSend a literal Ctrl-x to claude
Ctrl-x cToggle the change chronology bar on/off
Ctrl-x CSwap 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. Enter on a listed tag opens its body box; Enter on a name that isn't listed creates it.
  • 19 jump straight to a listed tag while the name field is empty.
  • Ctrl-d deletes 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:@me already 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.

SymbolMeaning
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] badgeSetup script exited non-zero; workspace is otherwise usable (? then o shows the log)

Activity column for running sessions:

  • active — output within the last 2 seconds
  • idle — output within the last 30 seconds
  • waiting — no output for over 30 seconds
  • off — no current session
  • resumable — 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
~NNModified/staged/added/deleted tracked files
?NNUntracked files
↑NNCommits ahead of upstream
↓NNCommits 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_use event 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 reads awaiting and the sub-line shows └ ⚠ awaiting permission: <tool> (<age>).
  • The claude session has gone ≥30 seconds without producing PTY output (state flips from active or idle to waiting).

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 mode 1042) 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 with misc: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 dev on :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, then SIGKILL if it is still running. Both the dashboard's d and wsx workspace archive do this. --keep-worktree skips 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 lsof to 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"]
  ]
}
FieldTypeDefaultEffect
visiblebooltrueMaster toggle. When false, the bar is hidden entirely and Tab skips the reply input.
height.percentu830Target height as a percent of the terminal's rows. Clamped to [5, 80].
height.min_rowsu168Floor on the bar's height. Clamped to [4, 40].
height.max_rowsu1618Ceiling on the bar's height. Clamped to [4, 60]. If min_rows > max_rows, the two are swapped on save.
containerslist 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 DetailBarOverridevisible 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 warn with 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 u listing 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), with repo/name rows; 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 −N line 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. Press Esc to close — with a filter active, Esc clears 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:

    KeyAction
    Up / Down (or k / 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, Esc to 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 Enter are 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:

  1. Attach to a workspace as usual (Enter on the dashboard).
  2. Press Ctrl-x u to open the updates panel.
  3. Move to another workspace; press v (vertical) or s (horizontal) to add it as a new pane alongside the current one. Focus jumps to the new pane.
  4. Navigate between panes with Ctrl-x ←/→/↑/↓ — direction-aware walking up the split tree, like vim's Ctrl-w motions.
  5. 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 via wsx status set.
  • Recap linesgoal:, 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) show no recap yet — agent hasn't run since this feature landed instead.
  • Facts line: git counts (↑ahead ↓behind ~modified ?untracked), a PR chip (PR #241 open, PR #241 draft, PR merged, …) colored by lifecycle, active <age> ago from the workspace's last session activity, and a recap stale marker 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
EnterAttach to the selected workspace
/Filter cards by workspace name (type to narrow)
Esc / TabClear the filter (if active) / return focus
q / pClose the digest
rForce a git/PR cache refresh
Key (dashboard focused)Action
pToggle the digest
TabFocus 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:

KeyEffect
branch_prefixDefault branch prefix for repos with no per-repo override. Branches are named <prefix>/<workspace>.
custom_instructionsFree-text appended to claude's system prompt on every workspace spawn.
process_doctrineStanding "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_extraExtra 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_agentDefault 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_fontsRender nerd-font glyphs in the dashboard. Default ON; set to false / 0 / off to disable.
editor_cmdCommand 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_cmdCommand to run for [t] terminal on the dashboard. Spawned with cwd=worktree; {path} substituted in place if present. Examples: alacritty, kitty, gnome-terminal.
notificationsRing 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_questionBell 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_completeBell pattern when a workspace's agent finishes a turn. Same values. Default single.
notification_bell_permissionBell pattern when a workspace's agent is waiting on a permission prompt. Same values. Default single.
notification_bell_stalledBell pattern when a workspace's agent has stalled. Same values. Default triple.
themeBase 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_themeOpt-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_mirrorInherit MCP servers from the source repo into worktrees (see MCP server inheritance). Default ON; set to off / false / 0 / no to disable.
remote_controlPass --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_sandboxWhen 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_commandsNewline-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_tagsNewline-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.
remotesNewline-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_widthWidth (chars) of the ⎇ branch column on the dashboard. Default 28. Clamped to 10..=80.
dashboard_pr_widthWidth (chars) of the PR chip column (⏺ #123 open) on the dashboard. Default 16. Clamped to 8..=24.
dashboard_sort_modeHow 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_secsHow 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_configJSON 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_configJSON 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/:

FileLook
theme-starship.tomlPowerline blocks in six stepped greys with orange accents, in the style of a starship prompt.
theme-rose-pine.tomlRosé Pine (main) in an airline layout.
theme-rose-pine-moon.tomlRosé Pine Moon, the softer, slightly lighter dark variant, same layout.
theme-nord.tomlNord, same layout.
theme-nord0.tomlNord one step darker: base blocks on nord0, so the middle of each bar melts into a nord terminal background.
theme-jellybeans.tomlJellybeans, same layout.
theme-orange.tomlDark 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 $pr is 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 funnel module, 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. With bar_theme on, 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 (priority 50) 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.

KeyMeaning
formatThe left side, clipped at the right edge if too long.
right_formatFlush right.
styleBase style inherited by literal text, segment content, and the fill; inner styles can override it.
fillThe first character repeated across the unused gap.
fill_styleStyle 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

SyntaxMeaning
$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.
$$, \[, \(, \xLiteral 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.

SegmentVariablesNotes
brand$symbol $name $mark $viewThe wordmark. $name is workspace, $mark is x, $view names the view (dashboard). Dashboard header only.
group$label $tabsThe group: mode tabs; $tabs is opaque, with the active mode highlighted. Dashboard header only.
sort$label $tabsThe sort: mode tabs, same shape as group. Dashboard header only.
filter$needleThe live filter echo, absent when no filter is active; $needle is capped at 24 characters. Dashboard header only.
counts$repos $workspacesRegistered repo and workspace counts. Dashboard header only.
keys$key $labelOne pill per key hint. Clickable.
version$version
usage$label $sparkThe 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 $ageOne 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 $labelOne chip per pinned command. Clickable.
tags$index $labelThe 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 $keyOne 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 $countHidden at zero. Clickable.
diff$added $removedHidden 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:

  • style only reaches the output through $style. The stock formats of agent_bar, workspace, attention, agents, model_tokens, procs, and pr bind it ([…]($style)), so setting style on those works as written. The stock formats of keys, pins, version, usage, and diff style their parts directly instead, so a bare style = … on one of those has no effect unless you also put $style in its format.
  • Bare parentheses are the conditional-group syntax, so a literal pair must be escaped. The stock attention item 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 attention became 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 $items alias.
  • symbol is substituted into format, but the literal spacing around it stays. Emptying one ([pr] symbol = "") leaves the space that follows $symbol in the stock format; delete that space in format too 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.

VariableCounts
working waiting blocked doneworkspaces whose last wsx status set is that state
busyworkspaces parked on background work (hook-inferred)
unreportedworkspaces with no reported status
alertsworkspaces with an unacknowledged attention alert
awaiting stalled active idleworkspaces by live transcript classification
live_agentsworkspaces with a live (thinking or waiting) primary session
pr_none pr_draft pr_open pr_conflicted pr_merged pr_closedworkspaces by PR lifecycle
review_required changes_requested approvedPRs by review verdict
unresolvedunresolved review threads across the fleet
mergeablePRs that are open and approved
dirtyworkspaces with modified or untracked files
msgs_queuedagent-to-agent messages not yet delivered
workspaces repostotals (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_ompthe same, per agent kind (hermes reports no usage, so it is always empty)
icon_claude icon_pi icon_hermes icon_codex icon_ompthe 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:

ModeBehavior
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.
localwsx intercepts your first prompt's keystrokes locally and slugifies them. Zero tokens; literal text.
offNo 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:

  • / k and / j move the selection; g jumps to the top (newest), G to the bottom.
  • Enter on an entry opens the full-change detail modal for that entry.
  • Esc (or Ctrl-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 e to open the file in your editor at the changed line (requires editor_cmd — see below).
  • Press Esc or 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)

KeyAction
Ctrl-x cToggle the chronology bar on/off
Ctrl-x CSwap 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 single editor_cmd works 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.

FieldTypeDefaultEffect
visiblebooltrueMaster 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.percentu832Target width as a percent of the attach area's columns.
width.min_colsu1624Minimum width in columns.
width.max_colsu1660Maximum 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:

AgentCLI optionSourceConfig
claude (default)--agent claudeclaude binary (override via WSX_CLAUDE_BIN)Environment + ~/.claude.json MCP
pi--agent pipi binary, @earendil-works/pi-coding-agent (override via WSX_PI_BIN)~/.pi/
hermes--agent hermesnousresearch/hermes-agent~/.hermes/config.yaml (provider, model)
codex--agent codexcodex binary (override via WSX_CODEX_BIN)~/.codex/config.toml
omp--agent ompomp 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/exclude so it doesn't show up in git 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 \`` display for terminal/bash tool invocations). Tool-use counts treat all Hermes tool names as "other" for now — categorization into read/edit/write/bash buckets is a follow-up since Hermes uses lowercase tool names rather than Claude's capitalized convention. Still missing compared to Claude/Pi: edited-files tracking and pending-tool-use timing for permission-prompt detection.

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:

KeyAction
/Move through the add picker
EnterAdd the highlighted kind
aAdd one of every kind at once
xRemove the most-recently-added (non-primary) agent
EscClose 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:

AgentWhere the id comes fromRespawn
claudeReported 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>
codexThe thread-id in the notify payload wsx already receives after each turn.codex resume <id>
piMinted 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>
ompRead 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>
hermesNot 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:

VariableValue
WSX_WORKSPACE_IDThe workspace this agent belongs to
WSX_AGENT_INSTANCE_IDThis 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:

  • name
  • branch_prefix
  • base_branch
  • custom_instructions
  • setup_script
  • archive_script
  • pinned_commands
  • related_repos
  • detail_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_cmd setting → $VISUAL$EDITOR
  • Terminal: terminal_cmd setting → $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 status and git 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 VimEnter and FocusGained, 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-files force-opens the sidebar otherwise. The BufWinEnter hook 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.
  • :WsxContext forces 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:

  1. Run wsx context write on the editor's start and focus events (and when its chat panel opens), guarded against overlap.
  2. Ignore a non-zero exit; that is the "not a wsx worktree" signal.
  3. 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.
  4. 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 default Ctrl-b prefix (or anyone's Ctrl-a customization). 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-agent or 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 keep sh -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's brew shellenv) are invisible to sh -l — add them to ~/.profile too.
  • Workspaces created as shared on the host (either via wsx workspace create <repo> --shared or by converting an existing one with T).
  • A local ssh binary (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 addwsx 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 with wsx agent send --workspace <other-repo>/<slug> primary "<brief>" — do not cd into 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_prefix naming, 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 with wsx agent add, hands it the branch diff vs main, and has it report a risk assessment + gap analysis back via wsx 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 with wsx 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. Reply defer to have the new agent wait for your request instead. The outgoing agent sets itself done and 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 global branch_prefix setting 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:

Environment variables and on-disk storage/configuration locations.

VariablePurpose
WSX_RENAME_MODEAuto-rename mode: claude (default) / local / off
WSX_CLAUDE_BINPath to the claude binary (default: looked up via PATH). Used by tests to substitute cat.
WSX_HERMES_BINPath to the hermes binary (default: looked up via PATH). Only used when coding_agent is hermes.
WSX_HERMES_MODELModel 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_PROVIDERProvider 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_BINPath 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_MODELModel 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_BINPath to the codex binary (default: codex on PATH). Only used when coding_agent is codex.
WSX_CODEX_MODELModel passed to Codex as -m (e.g. gpt-5.4). Unset = Codex default.
WSX_WORKSPACE_IDInjected 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_IDInjected into each agent session: that specific agent instance. wsx agent send reads it to stamp the message sender. See Multi-agent workspaces.
EDITOREditor invoked by wsx config edit (default: vi)
VISUAL / EDITORFallback when editor_cmd is unset
TERMINALFallback when terminal_cmd is unset
XDG_STATE_HOMEBase for the wsx state directory (default: ~/.local/state)
RUST_LOGtracing filter (default: info); set wsx=debug for verbose logs
HOMEFallback for resolving the state directory
PathContents
~/.config/wsx/theme.toml (honors XDG_CONFIG_HOME)Optional bar theme file; see Themes. Reloaded while running.
$XDG_STATE_HOME/wsx/state.dbSQLite database: repos, workspaces, settings
$XDG_STATE_HOME/wsx/worktrees/<repo>/<workspace>/Worktree directories created by wsx
$XDG_STATE_HOME/wsx/context/<repo>/<workspace>.mdWorkspace context digest written by wsx context write for editor-hosted agents
$XDG_STATE_HOME/wsx/logs/wsx.logDaily-rotated tracing logs
~/.claude/projects/<encoded-cwd>/<session>.jsonlClaude 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

  1. Set the new version in Cargo.toml and run cargo check so Cargo.lock picks it up. Commit both files.

  2. 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:

RunnerTarget
macos-latestaarch64-apple-darwin
macos-latestx86_64-apple-darwin
ubuntu-22.04x86_64-unknown-linux-gnu
ubuntu-22.04-armaarch64-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 = false in Cargo.toml.
  • The sessionx git dependency. crates.io rejects a crate that depends on a git revision, so sessionx has 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.