Skip to main content

sessionmux

Two-panel switcher for Claude Code sessions in one terminal, keyboard-only, emacs-styled. Claude runs inside the app; no tmux.

┌──────────────────────────────────┬──────────────────────────┐
│ the Claude session you work in   │    Age  Session          │
│                                  │ ▶  1m   timbre           │
│ > ...                            │         ~/stuff/projects │
│                                  │ ⏳ 2h   crm              │
└──────────────────────────────────┴──────────────────────────┘
        main (left)                   list (right, as wide as its content)

Run

sessionmux                       # conversations of the current directory
sessionmux ~/stuff/projects      # or of another directory

a in the list switches to the conversations of every directory and back.

Install with pip install sessionmux (or uv tool install sessionmux), then run sessionmux. The textual / textual-tty dependencies come with it. Until 2026-10-19 the app was called claude-sessions.

Keys

Anywhere (also while Claude has the keys):

Key Action
Alt-s Show the list (if hidden) and focus it
Alt-z Hide / show the list

In the list:

Key Action
Enter Show the conversation in the main pane and focus it (first time: start its Claude)
n New conversation in this directory
a List this directory / all directories
Tab Back to Claude without switching
C-n C-p / j k / arrows Move
C-v / M-v Page down / up
M-< / M-> (or < / >) Top / bottom
g / t / r Set group / tags (comma-separated) / label
R Reload the list
z Hide the list
x Close the conversation: its Claude ends, the transcript stays, not reopened next start (asks while it runs)
d / D Delete the conversation (asks yes or no; see below)
q / C-q Quit: close every Claude opened here (asks yes or no while one runs)
? Show these keys (any key closes them)

Inside Claude every other key is Claude's (Ctrl+C, Ctrl+Q, Tab, ←, ...). App keys are Alt + a letter only: Textual reads Alt + a digit, < or > as other characters.

Each row is one conversation: its title, and under it (dimmed) the directory it works in now and its git branch. The mode-line shows the conversation in the main pane and its directory; with nothing shown, the directory (or "all directories") and the number of conversations. With all directories the header says Session · all. The list is as wide as its longest line, at least 36 columns and at most half the window.

The list's border and cursor are in the accent colour only while the list has the keys; when Claude has them, both are dimmed. Ages and order stay current (rescanned with every poll; the cursor stays on its conversation). After a delete the cursor goes to the row above. When the Claude in the main pane ends, the keys go back to the list.

Marks: ▶ shown in the main pane · ⏳ waiting for input · ⚙ working · ● running (here or elsewhere) · ○ open at the last run, not started yet.

How it works

  • Hosting — every Claude you open runs in its own pseudo-terminal inside the app, drawn by a textual-tty terminal widget (hosting.ClaudePane). All panes stay mounted in a ContentSwitcher; the hidden ones keep running, so switching back is instant (~100 ms). The first open starts Claude (~2 s): claude attach <id> for a background session, else claude --resume <id>, in the list's directory (another directory's conversation: in its own directory, via sh -c 'cd "$0" && exec "$@"', since textual-tty has no working-directory option). Quitting closes every pane's pseudo-terminal, and each Claude exits. There is no detach: the transcripts stay, and Enter resumes a conversation next time.
  • ← inside Claude — Claude moves the conversation to a new background session (the old transcript gets a continued-in line) and can show any other agent in the same pane. A continued-in chain is one row. Claude sets the terminal title to the shown session's name; the pane reports each new title, the app polls claude agents --json and matches the title once against that snapshot (never against one taken before the title: it may still carry old names), re-tags the pane, rescans when the session is new, and moves the list cursor to it. A title that names no listed agent yet (a continuation just made) is tried again with the next polls. The title is observed behaviour (Claude 2.1.302), not a documented interface; if two agents share the name, the pane keeps its tag.
  • Cursor — Claude uses the terminal's real cursor, so the app does too: textual-tty's painted cursor cell is off, and while a Claude pane has the keys the app shows your terminal's own cursor at Claude's cursor position (hidden while the list or a prompt has the keys). Textual has no public call for that, so the app writes the show/hide codes itself; Textual is pinned to 8.2.8 for it.
  • New conversation (n) — starts plain claude in the list's directory (also with all directories listed). Claude writes no transcript before the first message, but claude agents lists the new process at once with its session id: the app matches the pane's process id to learn it (a poll 3 s after the start, then every 10 s). Until then the mode-line says ▶ new conversation. The row appears with the first scan after the first message, and the cursor goes to it.
  • All directories (a) — every folder under ~/.claude/projects. The first read takes a few seconds (~4 s for ~1.9 GB here, in the background thread; the mode-line says reading transcripts…, typing stays at ~35 ms). A conversation from another directory starts its Claude in that directory: resumed from elsewhere, Claude loads it but works in the wrong directory. Claude keeps a transcript in the folder named after the real path of the directory it ran in; older transcripts can record a cwd that does not match their folder, so the app uses the recorded cwd when its real path gives the folder name, else an existing directory found from the folder name. When no such directory exists any more, Enter says so and opens nothing. This directory's list also reads the folder of its real path (the two differ for a symlinked path).
  • Claude exited (/exit) — the pane keeps its last screen and the mode-line says (ended); Enter on the row starts it again.
  • Delete (d / D) — Claude has no per-session delete (claude rm keeps the transcript; claude purge deletes a whole directory). D refuses while the conversation runs in another terminal, and when claude agents fails (then where it runs is not known); one delete runs at a time. Otherwise it runs claude stop + claude rm on its background sessions (stops if Claude refuses, e.g. a worktree with unpushed commits), closes its panes, waits for those processes to exit, then moves every transcript of the conversation to ~/.local/share/sessionmux/trash/<time>-<id>/ ($XDG_DATA_HOME). RESTORE.txt there lists where each file came from; move them back to undo. Empty the trash with rm -rf ~/.local/share/sessionmux/trash.
  • Open conversations across runs — what is open (and which one is shown, and a) is written at every change to $XDG_STATE_HOME/sessionmux/open/<encoded list dir>.json (default ~/.local/state; the XDG spec's place for "current state of the application that can be reused on a restart"; directories made with mode 0700; atomic write), so it is current even if the computer dies. The next start, once the first scan and poll are in, starts again only the conversation that was shown (the keys stay in the list) and marks the others ○; they start on Enter (each Claude costs ~2 s and a few hundred MB). Not started: one that runs in another terminal now. Dropped from it: a conversation you end (/exit), close (x) or delete; q keeps them all. A new conversation is kept once it has its session id. The conversations themselves never depend on this file: Claude writes every message to its transcript as it goes.
  • Memory — each open conversation is one running Claude until q or x.

Files (under src/sessionmux/):

  • sessions.py — reads Claude's transcripts (~/.claude/projects/<encoded-cwd>/<id>.jsonl, an internal format) into conversations; joins your labels/groups/tags; finds where to start a conversation's Claude. Transcripts reach hundreds of MB: only the few relevant lines are parsed, the newest cwd is read from the file's end, unchanged files come from a cache, and a file that grew is read only from where the last read stopped (Claude only appends). The app scans in a background thread, so the list never freezes.
  • agents.py — live state from claude agents --json (read-only, in a background thread every 10 s, after you open a conversation and after a new title; one poll at a time, and asked while one runs it polls once more after it, so snapshots arrive in order; each call costs ~0.4 s CPU; a failed call keeps the last snapshot), the title → session matcher, and stop/rm/wait for delete.
  • hosting.py — Target (how to open a conversation) and ClaudePane.
  • state.py — OpenState, the open conversations kept across runs (XDG state dir).
  • xdgdirs.py — the XDG data and state directories, and making them (0700).
  • legacy.py — moves the files of earlier versions to where they live now.
  • app.py — the Textual app.

The app's own files follow the XDG base directories (xdgdirs.py; a relative $XDG_* value is ignored, as the spec says; new directories are mode 0700):

What Where
Labels, groups, tags (keyed by session id) $XDG_DATA_HOME/sessionmux/meta.json (~/.local/share/...)
Trash $XDG_DATA_HOME/sessionmux/trash/
Open conversations, per list directory $XDG_STATE_HOME/sessionmux/open/ (~/.local/state/...)

Each label change is a read-modify-write under a lock (meta.json.lock, flock: also between two running apps) and an atomic write (temp file + rename). Claude's own files under ~/.claude are only read, and moved by delete. Earlier versions kept them elsewhere: until 2026-10-18 the labels and the trash in ~/.claude (session-switcher-meta.json, session-switcher-trash/), until 2026-10-19 under the old name (~/.local/share/claude-sessions/, ~/.local/state/claude-sessions/). The app moves them once at start (legacy.py; a file already in the new place wins; each trash folder's RESTORE.txt gets its new paths) and says what it moved.

On quit, Textual cancels the background workers but cannot stop their threads: a scan stops at the next file, and nothing a worker sends after that is applied.

Crashes of the app are logged to logs/switcher.err.

Tests

uv run pytest -q
  • tests/test_sessions.py — transcript parsing (also appended and half-written lines), continued-in chains, rows, every directory, where a conversation starts, sidecar, trash (hand-written transcripts in a temporary CLAUDE_CONFIG_DIR).
  • tests/test_agents.py — claude agents --json parsing (faked), the title matcher, stop/rm, waiting for processes (zombies count as gone).
  • tests/test_app.py — how Enter opens a row, the list width, the list UI, delete, a.
  • tests/test_state.py — the state file: XDG paths, 0700 directories, shape, atomic write.
  • tests/test_legacy.py — moving the files of earlier versions.

Every test runs with CLAUDE_CONFIG_DIR, XDG_DATA_HOME and XDG_STATE_HOME in a temporary directory (autouse fixtures in tests/conftest.py), never the real ones.

  • tests/test_hosting.py — the panes with a fake claude in a real pseudo-terminal: start, switch back (same process), keys reach Claude, Alt-z/Alt-s/Tab, Ctrl+Q inside Claude does not quit, q closes every Claude, restart after exit, ← retagging, delete closes the pane, no process left behind, n (id from the process, the row appears, the cursor follows), another directory's conversation runs there.

Metadata

Release files for sessionmux 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sessionmux 0.1.0
File Size Uploaded Provenance
sessionmux-0.1.0.tar.gz 60.3 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for sessionmux 0.1.0
File Interpreter ABI Platform
sessionmux-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 101.2 kB

Release files / sessionmux-0.1.0.tar.gz

Download URL sessionmux-0.1.0.tar.gz
Size 60.3 kB
Tags Source
SHA-256 checksum
How to use checksums
677c998cb84f0d07046f56932b8211c316bb00ac02d7f0d67874f2f49ed66159
BLAKE2b-256 checksum
How to use checksums
1a90762a1291f5b7f78c4e411aeb389957ad38221dd6ca7501af1a0187caaffc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.11.19 {"installer":{"name":"uv","version":"0.11.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / sessionmux-0.1.0-py3-none-any.whl

Download URL sessionmux-0.1.0-py3-none-any.whl
Size 40.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
39267416ce5d8e416ae18cca31a43805a5590789c9115a41458f683f8d30aa5a
BLAKE2b-256 checksum
How to use checksums
c5c602876ad69f1398d13d8a66205b9b6217e47fd5e86b6508e8b8a8841ae69f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.11.19 {"installer":{"name":"uv","version":"0.11.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Security for sessionmux 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page