Metadata-Version: 2.5
Name: sessionmux
Version: 0.1.1
Summary: Two-panel, keyboard-only switcher for Claude Code sessions in one terminal
Project-URL: Homepage, https://gitlab.com/cardona-oss/sessionmux
Project-URL: Repository, https://gitlab.com/cardona-oss/sessionmux
Project-URL: Issues, https://gitlab.com/cardona-oss/sessionmux/-/issues
Project-URL: Changelog, https://gitlab.com/cardona-oss/sessionmux/-/blob/main/CHANGELOG.md
Project-URL: Documentation, https://cardona-oss.gitlab.io/sessionmux/
Author-email: Jorge Cardona <jorgeecardona@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: claude,claude-code,multiplexer,session,terminal,textual,tui
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Terminals
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: textual-tty==0.4.0
Requires-Dist: textual==8.2.8
Provides-Extra: docs
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
Requires-Dist: mkdocs>=1.6; extra == 'docs'
Requires-Dist: mkdocstrings[python]>=0.27; extra == 'docs'
Description-Content-Type: text/markdown

# sessionmux

[![pipeline status](https://gitlab.com/cardona-oss/sessionmux/badges/main/pipeline.svg)](https://gitlab.com/cardona-oss/sessionmux/-/pipelines)
[![PyPI version](https://img.shields.io/pypi/v/sessionmux.svg)](https://pypi.org/project/sessionmux/)
[![Python versions](https://img.shields.io/pypi/pyversions/sessionmux.svg)](https://pypi.org/project/sessionmux/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://gitlab.com/cardona-oss/sessionmux/-/blob/main/LICENSE)

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

```bash
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

```bash
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`.

## Links

- **Repository:** https://gitlab.com/cardona-oss/sessionmux
- **PyPI:** https://pypi.org/project/sessionmux/
- **Changelog:** [CHANGELOG.md](https://gitlab.com/cardona-oss/sessionmux/-/blob/main/CHANGELOG.md)

## Contributing

Toolchain, CI, and the release process live in [CONTRIBUTING.md](https://gitlab.com/cardona-oss/sessionmux/-/blob/main/CONTRIBUTING.md); build and test
detail for coding agents is in [AGENTS.md](https://gitlab.com/cardona-oss/sessionmux/-/blob/main/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](https://gitlab.com/cardona-oss/sessionmux/-/blob/main/LICENSE).
