Skip to main content

sessionmux

pipeline status PyPI version Python versions License: MIT

Two-panel, keyboard-only switcher for Claude Code sessions in one terminal, emacs-styled. For anyone juggling several Claude Code conversations at once. 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)

Why sessionmux

  • One terminal, every conversation. Each Claude you open runs in its own pseudo-terminal inside the app. Hidden ones keep running; switching back takes ~100 ms.
  • The list is the index. Every conversation of the current directory (or of all directories with a), with its age, state, directory and git branch. Enter resumes it, C-n starts a new one.
  • It remembers. What was open is written to the XDG state directory at every change, so a restart brings back the conversation you were on, even after the computer dies.

What it does not do:

  • Detach. Quitting ends every Claude opened here; the transcripts stay, and Enter resumes a conversation next time.
  • Take Claude's keys. Inside Claude every key but Alt-s / Alt-z is Claude's.
  • Delete without a net. C-d moves transcripts to a trash folder with a RESTORE.txt; nothing is erased until you empty it.

Install

pip install sessionmux       # or: uv tool install sessionmux

Python 3.11+. The textual / textual-tty dependencies come with it. Until 2026-10-19 the app was called claude-sessions; earlier files are moved on first start (see below).

Quickstart

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

Then, in the list: Enter opens the conversation under the cursor in the main pane (first time: starts its Claude), C-n starts a new conversation here, a switches to the conversations of every directory and back. From inside Claude, Alt-s returns to the list.

Key features

  • Hosting. Every Claude runs in its own pseudo-terminal inside the app (textual-tty); all panes stay mounted, so switching is instant and nothing is restarted.
  • Live state. Marks in the list: ▶ shown in the main pane, ⏳ waiting for input, ⚙ working, ● running (here or elsewhere), ○ open at the last run, not started yet. Ages and order are rescanned with every poll.
  • Follows Claude's own navigation. ← inside Claude moves the conversation to a new background session; the app re-tags the pane from the terminal title and moves the cursor to it.
  • All directories. a lists every folder under ~/.claude/projects; a conversation from another directory starts its Claude in that directory.
  • Labels, groups, tags. l / g / t on a row; stored keyed by session id, outside ~/.claude.
  • Open conversations across runs. Restored on the next start; only the one that was shown is started again, the others wait for Enter.
  • Delete to trash. C-d stops the session, closes its panes, and moves every transcript to a dated trash folder with a RESTORE.txt.
  • Large transcripts stay cheap. Only the relevant lines are parsed, a file that grew is read from where the last read stopped, and scanning runs in a background thread.

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)
C-n New conversation in this directory
C-r Restart: its Claude ends and starts again on the same conversation (not open: starts it)
C-d Delete the conversation (asks yes or no; see below)
a List this directory / all directories
Tab Back to Claude without switching
j k / arrows Move
l / g / t Set label / group / tags (comma-separated)
x Close the conversation: its Claude ends, the transcript stays, not reopened next start (asks while it runs)
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. 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.

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 (C-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 (C-d). Claude has no per-session delete (claude rm keeps the transcript; claude purge deletes a whole directory). C-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.

Modules

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.

Files

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.

Contributing

Toolchain, CI, and the release process live in CONTRIBUTING.md; build and test detail for coding agents is in AGENTS.md. In short: make setup once, then make check (lint + format-check + typecheck + test); moon run :ci is what CI runs.

The suite (uv run pytest -q) 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_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.
  • 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, C-n (id from the process, the row appears, the cursor follows), another directory's conversation runs there.

License

MIT © Jorge Cardona. See LICENSE.

Metadata

Release files for sessionmux 0.1.1

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.1
File Size Uploaded Provenance
sessionmux-0.1.1.tar.gz 62.2 kB Without provenance Details

Built distribution (wheel)

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

Total release size: 104.5 kB

Release files / sessionmux-0.1.1.tar.gz

Download URL sessionmux-0.1.1.tar.gz
Size 62.2 kB
Tags Source
SHA-256 checksum
How to use checksums
c6e2f83043a84e99fff8391d3999addfd2ea019c301c12365840ae9c3d447d30
BLAKE2b-256 checksum
How to use checksums
0ed3aa808c8600e7b1816e1c47c9c7805b40ffb22f44775bc651f0ea79368b19
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.1-py3-none-any.whl

Download URL sessionmux-0.1.1-py3-none-any.whl
Size 42.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1ef18ca7c1f74d393b20db516796993cb70bb725dd822aa02967a8181f14fa13
BLAKE2b-256 checksum
How to use checksums
d17b196f7df7d74df04f62282be82033135b9b45c410f73c6e5e0ac0df4657c4
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.1

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