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-ttyterminal widget (hosting.ClaudePane). All panes stay mounted in aContentSwitcher; 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, elseclaude --resume <id>, in the list's directory (another directory's conversation: in its own directory, viash -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, andEnterresumes a conversation next time. ←inside Claude — Claude moves the conversation to a new background session (the old transcript gets acontinued-inline) and can show any other agent in the same pane. Acontinued-inchain is one row. Claude sets the terminal title to the shown session's name; the pane reports each new title, the app pollsclaude agents --jsonand 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 plainclaudein the list's directory (also with all directories listed). Claude writes no transcript before the first message, butclaude agentslists 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 saysreading 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 acwdthat does not match their folder, so the app uses the recordedcwdwhen its real path gives the folder name, else an existing directory found from the folder name. When no such directory exists any more,Entersays 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);Enteron the row starts it again. - Delete (
d/D) — Claude has no per-session delete (claude rmkeeps the transcript;claude purgedeletes a whole directory).Drefuses while the conversation runs in another terminal, and whenclaude agentsfails (then where it runs is not known); one delete runs at a time. Otherwise it runsclaude stop+claude rmon 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.txtthere lists where each file came from; move them back to undo. Empty the trash withrm -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 onEnter(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;qkeeps 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
qorx.
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 newestcwdis 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 fromclaude 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) andClaudePane.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-inchains, rows, every directory, where a conversation starts, sidecar, trash (hand-written transcripts in a temporaryCLAUDE_CONFIG_DIR).tests/test_agents.py—claude agents --jsonparsing (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 fakeclaudein a real pseudo-terminal: start, switch back (same process), keys reach Claude, Alt-z/Alt-s/Tab, Ctrl+Q inside Claude does not quit,qcloses 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)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| sessionmux-0.1.0.tar.gz | 60.3 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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.