sessionmux 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sessionmux-0.1.0/.gitignore +46 -0
- sessionmux-0.1.0/CHANGELOG.md +21 -0
- sessionmux-0.1.0/LICENSE +21 -0
- sessionmux-0.1.0/PKG-INFO +254 -0
- sessionmux-0.1.0/README.md +222 -0
- sessionmux-0.1.0/docs/issues/README.md +21 -0
- sessionmux-0.1.0/pyproject.toml +117 -0
- sessionmux-0.1.0/src/sessionmux/__init__.py +3 -0
- sessionmux-0.1.0/src/sessionmux/agents.py +161 -0
- sessionmux-0.1.0/src/sessionmux/app.py +1232 -0
- sessionmux-0.1.0/src/sessionmux/hosting.py +156 -0
- sessionmux-0.1.0/src/sessionmux/legacy.py +128 -0
- sessionmux-0.1.0/src/sessionmux/py.typed +0 -0
- sessionmux-0.1.0/src/sessionmux/sessions.py +535 -0
- sessionmux-0.1.0/src/sessionmux/state.py +71 -0
- sessionmux-0.1.0/src/sessionmux/usage.py +279 -0
- sessionmux-0.1.0/src/sessionmux/xdgdirs.py +43 -0
- sessionmux-0.1.0/tests/conftest.py +87 -0
- sessionmux-0.1.0/tests/lines.py +84 -0
- sessionmux-0.1.0/tests/pilot.py +71 -0
- sessionmux-0.1.0/tests/test_agents.py +181 -0
- sessionmux-0.1.0/tests/test_app.py +518 -0
- sessionmux-0.1.0/tests/test_hosting.py +829 -0
- sessionmux-0.1.0/tests/test_legacy.py +139 -0
- sessionmux-0.1.0/tests/test_sessions.py +445 -0
- sessionmux-0.1.0/tests/test_state.py +94 -0
- sessionmux-0.1.0/tests/test_usage.py +197 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
|
|
7
|
+
# Build artifacts
|
|
8
|
+
/dist/
|
|
9
|
+
/build/
|
|
10
|
+
/site/
|
|
11
|
+
/public/
|
|
12
|
+
|
|
13
|
+
# uv / venv
|
|
14
|
+
.venv/
|
|
15
|
+
uv.lock.bak
|
|
16
|
+
|
|
17
|
+
# Tool caches
|
|
18
|
+
.pytest_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
.pyright/
|
|
21
|
+
.mypy_cache/
|
|
22
|
+
.moon/cache/
|
|
23
|
+
|
|
24
|
+
# Editor
|
|
25
|
+
.vscode/
|
|
26
|
+
.idea/
|
|
27
|
+
*.swp
|
|
28
|
+
|
|
29
|
+
# Local session logs
|
|
30
|
+
logs/
|
|
31
|
+
|
|
32
|
+
# Personal, machine-specific agent overrides (AGENTS.md is tracked)
|
|
33
|
+
AGENTS.local.md
|
|
34
|
+
CLAUDE.local.md
|
|
35
|
+
|
|
36
|
+
# Claude Code: per-machine local settings + worktrees are local; .claude/settings.json IS tracked.
|
|
37
|
+
.claude/settings.local.json
|
|
38
|
+
.claude/worktrees/
|
|
39
|
+
|
|
40
|
+
# Local agent wiring: names cabildo-issues-mcp, which is not a dependency of this
|
|
41
|
+
# package. docs/issues/ IS tracked — the register is plain markdown anyone can read.
|
|
42
|
+
.mcp.json
|
|
43
|
+
|
|
44
|
+
# sessionmux-specific: cabildo coordination dir and throwaway spikes are not shipped.
|
|
45
|
+
.cabildo/
|
|
46
|
+
spikes/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] — 2026-10-19
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Two-panel TUI: Claude Code session pane (left) + session list (right).
|
|
15
|
+
- Keyboard-only navigation; sessions keep running while hidden.
|
|
16
|
+
- `sessionmux [working-dir]` console entry point.
|
|
17
|
+
- XDG-compliant state persistence across restarts and crashes.
|
|
18
|
+
- Session labels, groups, and tags stored in XDG data directory.
|
|
19
|
+
- Trash and restore for closed sessions.
|
|
20
|
+
- CPU/memory/wakeup metrics panel (`T`).
|
|
21
|
+
- Migration of earlier `claude-sessions` and pre-XDG layouts.
|
sessionmux-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jorge Cardona
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sessionmux
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Two-panel, keyboard-only switcher for Claude Code sessions in one terminal
|
|
5
|
+
Project-URL: Homepage, https://gitlab.com/cardona-oss/sessionmux
|
|
6
|
+
Project-URL: Repository, https://gitlab.com/cardona-oss/sessionmux
|
|
7
|
+
Project-URL: Issues, https://gitlab.com/cardona-oss/sessionmux/-/issues
|
|
8
|
+
Project-URL: Changelog, https://gitlab.com/cardona-oss/sessionmux/-/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Documentation, https://cardona-oss.gitlab.io/sessionmux/
|
|
10
|
+
Author-email: Jorge Cardona <jorgeecardona@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: claude,claude-code,multiplexer,session,terminal,textual,tui
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Topic :: Terminals
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: textual-tty==0.4.0
|
|
26
|
+
Requires-Dist: textual==8.2.8
|
|
27
|
+
Provides-Extra: docs
|
|
28
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
29
|
+
Requires-Dist: mkdocs>=1.6; extra == 'docs'
|
|
30
|
+
Requires-Dist: mkdocstrings[python]>=0.27; extra == 'docs'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# sessionmux
|
|
34
|
+
|
|
35
|
+
Two-panel switcher for Claude Code sessions in one terminal, keyboard-only,
|
|
36
|
+
emacs-styled. Claude runs inside the app; no tmux.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
┌──────────────────────────────────┬──────────────────────────┐
|
|
40
|
+
│ the Claude session you work in │ Age Session │
|
|
41
|
+
│ │ ▶ 1m timbre │
|
|
42
|
+
│ > ... │ ~/stuff/projects │
|
|
43
|
+
│ │ ⏳ 2h crm │
|
|
44
|
+
└──────────────────────────────────┴──────────────────────────┘
|
|
45
|
+
main (left) list (right, as wide as its content)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Run
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
sessionmux # conversations of the current directory
|
|
52
|
+
sessionmux ~/stuff/projects # or of another directory
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`a` in the list switches to the conversations of every directory and back.
|
|
56
|
+
|
|
57
|
+
Install with `pip install sessionmux` (or `uv tool install sessionmux`), then run
|
|
58
|
+
`sessionmux`. The `textual` / `textual-tty` dependencies come with it. Until
|
|
59
|
+
2026-10-19 the app was called `claude-sessions`.
|
|
60
|
+
|
|
61
|
+
## Keys
|
|
62
|
+
|
|
63
|
+
Anywhere (also while Claude has the keys):
|
|
64
|
+
|
|
65
|
+
| Key | Action |
|
|
66
|
+
|-----|--------|
|
|
67
|
+
| `Alt-s` | Show the list (if hidden) and focus it |
|
|
68
|
+
| `Alt-z` | Hide / show the list |
|
|
69
|
+
|
|
70
|
+
In the list:
|
|
71
|
+
|
|
72
|
+
| Key | Action |
|
|
73
|
+
|-----|--------|
|
|
74
|
+
| `Enter` | Show the conversation in the main pane and focus it (first time: start its Claude) |
|
|
75
|
+
| `n` | New conversation in this directory |
|
|
76
|
+
| `a` | List this directory / all directories |
|
|
77
|
+
| `Tab` | Back to Claude without switching |
|
|
78
|
+
| `C-n` `C-p` / `j` `k` / arrows | Move |
|
|
79
|
+
| `C-v` / `M-v` | Page down / up |
|
|
80
|
+
| `M-<` / `M->` (or `<` / `>`) | Top / bottom |
|
|
81
|
+
| `g` / `t` / `r` | Set group / tags (comma-separated) / label |
|
|
82
|
+
| `R` | Reload the list |
|
|
83
|
+
| `z` | Hide the list |
|
|
84
|
+
| `x` | Close the conversation: its Claude ends, the transcript stays, not reopened next start (asks while it runs) |
|
|
85
|
+
| `d` / `D` | Delete the conversation (asks `yes or no`; see below) |
|
|
86
|
+
| `q` / `C-q` | Quit: close every Claude opened here (asks `yes or no` while one runs) |
|
|
87
|
+
| `?` | Show these keys (any key closes them) |
|
|
88
|
+
|
|
89
|
+
Inside Claude every other key is Claude's (Ctrl+C, Ctrl+Q, Tab, `←`, ...).
|
|
90
|
+
App keys are Alt + a letter only: Textual reads Alt + a digit, `<` or `>` as
|
|
91
|
+
other characters.
|
|
92
|
+
|
|
93
|
+
Each row is one conversation: its title, and under it (dimmed) the directory
|
|
94
|
+
it works in now and its git branch. The mode-line shows the conversation in the
|
|
95
|
+
main pane and its directory; with nothing shown, the directory (or "all
|
|
96
|
+
directories") and the number of conversations. With all directories the header
|
|
97
|
+
says `Session · all`. The list is as wide as its longest line, at least
|
|
98
|
+
36 columns and at most half the window.
|
|
99
|
+
|
|
100
|
+
The list's border and cursor are in the accent colour only while the list has
|
|
101
|
+
the keys; when Claude has them, both are dimmed. Ages and order stay current
|
|
102
|
+
(rescanned with every poll; the cursor stays on its conversation). After a
|
|
103
|
+
delete the cursor goes to the row above. When the Claude in the main pane
|
|
104
|
+
ends, the keys go back to the list.
|
|
105
|
+
|
|
106
|
+
Marks: `▶` shown in the main pane · `⏳` waiting for input · `⚙` working ·
|
|
107
|
+
`●` running (here or elsewhere) · `○` open at the last run, not started yet.
|
|
108
|
+
|
|
109
|
+
## How it works
|
|
110
|
+
|
|
111
|
+
- **Hosting** — every Claude you open runs in its own pseudo-terminal inside
|
|
112
|
+
the app, drawn by a `textual-tty` terminal widget (`hosting.ClaudePane`). All
|
|
113
|
+
panes stay mounted in a `ContentSwitcher`; the hidden ones keep running, so
|
|
114
|
+
switching back is instant (~100 ms). The first open starts Claude (~2 s):
|
|
115
|
+
`claude attach <id>` for a background session, else `claude --resume <id>`,
|
|
116
|
+
in the list's directory (another directory's conversation: in its own
|
|
117
|
+
directory, via `sh -c 'cd "$0" && exec "$@"'`, since textual-tty has no
|
|
118
|
+
working-directory option). Quitting closes every pane's pseudo-terminal, and
|
|
119
|
+
each Claude exits. There is no detach: the transcripts stay, and `Enter`
|
|
120
|
+
resumes a conversation next time.
|
|
121
|
+
- **`←` inside Claude** — Claude moves the conversation to a new background
|
|
122
|
+
session (the old transcript gets a `continued-in` line) and can show any
|
|
123
|
+
other agent in the same pane. A `continued-in` chain is one row. Claude sets
|
|
124
|
+
the terminal title to the shown session's name; the pane reports each new
|
|
125
|
+
title, the app polls `claude agents --json` and matches the title once
|
|
126
|
+
against that snapshot (never against one taken before the title: it may
|
|
127
|
+
still carry old names), re-tags the pane, rescans when the session is new,
|
|
128
|
+
and moves the list cursor to it. A title that names no listed agent yet (a
|
|
129
|
+
continuation just made) is tried again with the next polls. The title is
|
|
130
|
+
observed behaviour (Claude 2.1.302), not a documented interface; if two
|
|
131
|
+
agents share the name, the pane keeps its tag.
|
|
132
|
+
- **Cursor** — Claude uses the terminal's real cursor, so the app does too:
|
|
133
|
+
textual-tty's painted cursor cell is off, and while a Claude pane has the
|
|
134
|
+
keys the app shows your terminal's own cursor at Claude's cursor position
|
|
135
|
+
(hidden while the list or a prompt has the keys). Textual has no public call
|
|
136
|
+
for that, so the app writes the show/hide codes itself; Textual is pinned to
|
|
137
|
+
8.2.8 for it.
|
|
138
|
+
- **New conversation (`n`)** — starts plain `claude` in the list's directory
|
|
139
|
+
(also with all directories listed). Claude writes no transcript before the
|
|
140
|
+
first message, but `claude agents` lists the new process at once with its
|
|
141
|
+
session id: the app matches the pane's process id to learn it (a poll 3 s
|
|
142
|
+
after the start, then every 10 s). Until then the mode-line says
|
|
143
|
+
`▶ new conversation`. The row appears with the first scan after the first
|
|
144
|
+
message, and the cursor goes to it.
|
|
145
|
+
- **All directories (`a`)** — every folder under `~/.claude/projects`. The
|
|
146
|
+
first read takes a few seconds (~4 s for ~1.9 GB here, in the background
|
|
147
|
+
thread; the mode-line says `reading transcripts…`, typing stays at ~35 ms).
|
|
148
|
+
A conversation from another directory starts its Claude in that directory:
|
|
149
|
+
resumed from elsewhere, Claude loads it but works in the wrong directory.
|
|
150
|
+
Claude keeps a transcript in the folder named after the real path of the
|
|
151
|
+
directory it ran in; older transcripts can record a `cwd` that does not
|
|
152
|
+
match their folder, so the app uses the recorded `cwd` when its real path
|
|
153
|
+
gives the folder name, else an existing directory found from the folder
|
|
154
|
+
name. When no such directory exists any more, `Enter` says so and opens
|
|
155
|
+
nothing. This directory's list also reads the folder of its real path (the
|
|
156
|
+
two differ for a symlinked path).
|
|
157
|
+
- **Claude exited** (`/exit`) — the pane keeps its last screen and the
|
|
158
|
+
mode-line says `(ended)`; `Enter` on the row starts it again.
|
|
159
|
+
- **Delete (`d` / `D`)** — Claude has no per-session delete (`claude rm` keeps
|
|
160
|
+
the transcript; `claude purge` deletes a whole directory). `D` refuses while
|
|
161
|
+
the conversation runs in another terminal, and when `claude agents` fails
|
|
162
|
+
(then where it runs is not known); one delete runs at a time. Otherwise it
|
|
163
|
+
runs `claude stop` +
|
|
164
|
+
`claude rm` on its background sessions (stops if Claude refuses, e.g. a
|
|
165
|
+
worktree with unpushed commits), closes its panes, waits for those processes
|
|
166
|
+
to exit, then moves every transcript of the conversation to
|
|
167
|
+
`~/.local/share/sessionmux/trash/<time>-<id>/` (`$XDG_DATA_HOME`).
|
|
168
|
+
`RESTORE.txt` there lists where each file came from; move them back to undo.
|
|
169
|
+
Empty the trash with `rm -rf ~/.local/share/sessionmux/trash`.
|
|
170
|
+
- **Open conversations across runs** — what is open (and which one is shown,
|
|
171
|
+
and `a`) is written at every change to
|
|
172
|
+
`$XDG_STATE_HOME/sessionmux/open/<encoded list dir>.json` (default
|
|
173
|
+
`~/.local/state`; the XDG spec's place for "current state of the application
|
|
174
|
+
that can be reused on a restart"; directories made with mode 0700; atomic
|
|
175
|
+
write), so it is current even if the computer dies. The next start, once the
|
|
176
|
+
first scan and poll are in, starts again only the conversation that was
|
|
177
|
+
shown (the keys stay in the list) and marks the others `○`; they start on
|
|
178
|
+
`Enter` (each Claude costs ~2 s and a few hundred MB). Not started: one that
|
|
179
|
+
runs in another terminal now. Dropped from it: a conversation you end
|
|
180
|
+
(`/exit`), close (`x`) or delete; `q` keeps them all. A new conversation is
|
|
181
|
+
kept once it has its session id. The conversations themselves never depend
|
|
182
|
+
on this file: Claude writes every message to its transcript as it goes.
|
|
183
|
+
- **Memory** — each open conversation is one running Claude until `q` or `x`.
|
|
184
|
+
|
|
185
|
+
Files (under `src/sessionmux/`):
|
|
186
|
+
|
|
187
|
+
- `sessions.py` — reads Claude's transcripts
|
|
188
|
+
(`~/.claude/projects/<encoded-cwd>/<id>.jsonl`, an internal format) into
|
|
189
|
+
conversations; joins your labels/groups/tags; finds where to start a
|
|
190
|
+
conversation's Claude. Transcripts reach hundreds of MB: only the few
|
|
191
|
+
relevant lines are parsed, the newest `cwd` is read from the file's end,
|
|
192
|
+
unchanged files come from a cache, and a file that grew is read only from
|
|
193
|
+
where the last read stopped (Claude only appends). The app scans in a
|
|
194
|
+
background thread, so the list never freezes.
|
|
195
|
+
- `agents.py` — live state from `claude agents --json` (read-only, in a
|
|
196
|
+
background thread every 10 s, after you open a conversation and after a new
|
|
197
|
+
title; one poll at a time, and asked while one runs it polls once more after
|
|
198
|
+
it, so snapshots arrive in order; each call costs ~0.4 s CPU; a failed call
|
|
199
|
+
keeps the last snapshot), the title → session matcher, and stop/rm/wait for
|
|
200
|
+
delete.
|
|
201
|
+
- `hosting.py` — `Target` (how to open a conversation) and `ClaudePane`.
|
|
202
|
+
- `state.py` — `OpenState`, the open conversations kept across runs (XDG state dir).
|
|
203
|
+
- `xdgdirs.py` — the XDG data and state directories, and making them (0700).
|
|
204
|
+
- `legacy.py` — moves the files of earlier versions to where they live now.
|
|
205
|
+
- `app.py` — the Textual app.
|
|
206
|
+
|
|
207
|
+
The app's own files follow the XDG base directories (`xdgdirs.py`; a relative
|
|
208
|
+
`$XDG_*` value is ignored, as the spec says; new directories are mode 0700):
|
|
209
|
+
|
|
210
|
+
| What | Where |
|
|
211
|
+
|---|---|
|
|
212
|
+
| Labels, groups, tags (keyed by session id) | `$XDG_DATA_HOME/sessionmux/meta.json` (`~/.local/share/...`) |
|
|
213
|
+
| Trash | `$XDG_DATA_HOME/sessionmux/trash/` |
|
|
214
|
+
| Open conversations, per list directory | `$XDG_STATE_HOME/sessionmux/open/` (`~/.local/state/...`) |
|
|
215
|
+
|
|
216
|
+
Each label change is a read-modify-write under a lock (`meta.json.lock`,
|
|
217
|
+
flock: also between two running apps) and an atomic write (temp file +
|
|
218
|
+
rename). Claude's own files under `~/.claude` are only read, and moved by
|
|
219
|
+
delete. Earlier versions kept them elsewhere: until 2026-10-18 the labels and
|
|
220
|
+
the trash in `~/.claude` (`session-switcher-meta.json`,
|
|
221
|
+
`session-switcher-trash/`), until 2026-10-19 under the old name
|
|
222
|
+
(`~/.local/share/claude-sessions/`, `~/.local/state/claude-sessions/`). The
|
|
223
|
+
app moves them once at start (`legacy.py`; a file already in the new place
|
|
224
|
+
wins; each trash folder's `RESTORE.txt` gets its new paths) and says what it
|
|
225
|
+
moved.
|
|
226
|
+
|
|
227
|
+
On quit, Textual cancels the background workers but cannot stop their
|
|
228
|
+
threads: a scan stops at the next file, and nothing a worker sends after that
|
|
229
|
+
is applied.
|
|
230
|
+
|
|
231
|
+
Crashes of the app are logged to `logs/switcher.err`.
|
|
232
|
+
|
|
233
|
+
## Tests
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
uv run pytest -q
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
- `tests/test_sessions.py` — transcript parsing (also appended and half-written lines),
|
|
240
|
+
`continued-in` chains, rows, every directory, where a conversation starts, sidecar, trash
|
|
241
|
+
(hand-written transcripts in a temporary `CLAUDE_CONFIG_DIR`).
|
|
242
|
+
- `tests/test_agents.py` — `claude agents --json` parsing (faked), the title matcher,
|
|
243
|
+
stop/rm, waiting for processes (zombies count as gone).
|
|
244
|
+
- `tests/test_app.py` — how Enter opens a row, the list width, the list UI, delete, `a`.
|
|
245
|
+
- `tests/test_state.py` — the state file: XDG paths, 0700 directories, shape, atomic write.
|
|
246
|
+
- `tests/test_legacy.py` — moving the files of earlier versions.
|
|
247
|
+
|
|
248
|
+
Every test runs with `CLAUDE_CONFIG_DIR`, `XDG_DATA_HOME` and `XDG_STATE_HOME` in
|
|
249
|
+
a temporary directory (autouse fixtures in `tests/conftest.py`), never the real ones.
|
|
250
|
+
- `tests/test_hosting.py` — the panes with a fake `claude` in a real pseudo-terminal:
|
|
251
|
+
start, switch back (same process), keys reach Claude, Alt-z/Alt-s/Tab, Ctrl+Q inside
|
|
252
|
+
Claude does not quit, `q` closes every Claude, restart after exit, ← retagging,
|
|
253
|
+
delete closes the pane, no process left behind, `n` (id from the process, the row
|
|
254
|
+
appears, the cursor follows), another directory's conversation runs there.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# sessionmux
|
|
2
|
+
|
|
3
|
+
Two-panel switcher for Claude Code sessions in one terminal, keyboard-only,
|
|
4
|
+
emacs-styled. Claude runs inside the app; no tmux.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
┌──────────────────────────────────┬──────────────────────────┐
|
|
8
|
+
│ the Claude session you work in │ Age Session │
|
|
9
|
+
│ │ ▶ 1m timbre │
|
|
10
|
+
│ > ... │ ~/stuff/projects │
|
|
11
|
+
│ │ ⏳ 2h crm │
|
|
12
|
+
└──────────────────────────────────┴──────────────────────────┘
|
|
13
|
+
main (left) list (right, as wide as its content)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Run
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
sessionmux # conversations of the current directory
|
|
20
|
+
sessionmux ~/stuff/projects # or of another directory
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`a` in the list switches to the conversations of every directory and back.
|
|
24
|
+
|
|
25
|
+
Install with `pip install sessionmux` (or `uv tool install sessionmux`), then run
|
|
26
|
+
`sessionmux`. The `textual` / `textual-tty` dependencies come with it. Until
|
|
27
|
+
2026-10-19 the app was called `claude-sessions`.
|
|
28
|
+
|
|
29
|
+
## Keys
|
|
30
|
+
|
|
31
|
+
Anywhere (also while Claude has the keys):
|
|
32
|
+
|
|
33
|
+
| Key | Action |
|
|
34
|
+
|-----|--------|
|
|
35
|
+
| `Alt-s` | Show the list (if hidden) and focus it |
|
|
36
|
+
| `Alt-z` | Hide / show the list |
|
|
37
|
+
|
|
38
|
+
In the list:
|
|
39
|
+
|
|
40
|
+
| Key | Action |
|
|
41
|
+
|-----|--------|
|
|
42
|
+
| `Enter` | Show the conversation in the main pane and focus it (first time: start its Claude) |
|
|
43
|
+
| `n` | New conversation in this directory |
|
|
44
|
+
| `a` | List this directory / all directories |
|
|
45
|
+
| `Tab` | Back to Claude without switching |
|
|
46
|
+
| `C-n` `C-p` / `j` `k` / arrows | Move |
|
|
47
|
+
| `C-v` / `M-v` | Page down / up |
|
|
48
|
+
| `M-<` / `M->` (or `<` / `>`) | Top / bottom |
|
|
49
|
+
| `g` / `t` / `r` | Set group / tags (comma-separated) / label |
|
|
50
|
+
| `R` | Reload the list |
|
|
51
|
+
| `z` | Hide the list |
|
|
52
|
+
| `x` | Close the conversation: its Claude ends, the transcript stays, not reopened next start (asks while it runs) |
|
|
53
|
+
| `d` / `D` | Delete the conversation (asks `yes or no`; see below) |
|
|
54
|
+
| `q` / `C-q` | Quit: close every Claude opened here (asks `yes or no` while one runs) |
|
|
55
|
+
| `?` | Show these keys (any key closes them) |
|
|
56
|
+
|
|
57
|
+
Inside Claude every other key is Claude's (Ctrl+C, Ctrl+Q, Tab, `←`, ...).
|
|
58
|
+
App keys are Alt + a letter only: Textual reads Alt + a digit, `<` or `>` as
|
|
59
|
+
other characters.
|
|
60
|
+
|
|
61
|
+
Each row is one conversation: its title, and under it (dimmed) the directory
|
|
62
|
+
it works in now and its git branch. The mode-line shows the conversation in the
|
|
63
|
+
main pane and its directory; with nothing shown, the directory (or "all
|
|
64
|
+
directories") and the number of conversations. With all directories the header
|
|
65
|
+
says `Session · all`. The list is as wide as its longest line, at least
|
|
66
|
+
36 columns and at most half the window.
|
|
67
|
+
|
|
68
|
+
The list's border and cursor are in the accent colour only while the list has
|
|
69
|
+
the keys; when Claude has them, both are dimmed. Ages and order stay current
|
|
70
|
+
(rescanned with every poll; the cursor stays on its conversation). After a
|
|
71
|
+
delete the cursor goes to the row above. When the Claude in the main pane
|
|
72
|
+
ends, the keys go back to the list.
|
|
73
|
+
|
|
74
|
+
Marks: `▶` shown in the main pane · `⏳` waiting for input · `⚙` working ·
|
|
75
|
+
`●` running (here or elsewhere) · `○` open at the last run, not started yet.
|
|
76
|
+
|
|
77
|
+
## How it works
|
|
78
|
+
|
|
79
|
+
- **Hosting** — every Claude you open runs in its own pseudo-terminal inside
|
|
80
|
+
the app, drawn by a `textual-tty` terminal widget (`hosting.ClaudePane`). All
|
|
81
|
+
panes stay mounted in a `ContentSwitcher`; the hidden ones keep running, so
|
|
82
|
+
switching back is instant (~100 ms). The first open starts Claude (~2 s):
|
|
83
|
+
`claude attach <id>` for a background session, else `claude --resume <id>`,
|
|
84
|
+
in the list's directory (another directory's conversation: in its own
|
|
85
|
+
directory, via `sh -c 'cd "$0" && exec "$@"'`, since textual-tty has no
|
|
86
|
+
working-directory option). Quitting closes every pane's pseudo-terminal, and
|
|
87
|
+
each Claude exits. There is no detach: the transcripts stay, and `Enter`
|
|
88
|
+
resumes a conversation next time.
|
|
89
|
+
- **`←` inside Claude** — Claude moves the conversation to a new background
|
|
90
|
+
session (the old transcript gets a `continued-in` line) and can show any
|
|
91
|
+
other agent in the same pane. A `continued-in` chain is one row. Claude sets
|
|
92
|
+
the terminal title to the shown session's name; the pane reports each new
|
|
93
|
+
title, the app polls `claude agents --json` and matches the title once
|
|
94
|
+
against that snapshot (never against one taken before the title: it may
|
|
95
|
+
still carry old names), re-tags the pane, rescans when the session is new,
|
|
96
|
+
and moves the list cursor to it. A title that names no listed agent yet (a
|
|
97
|
+
continuation just made) is tried again with the next polls. The title is
|
|
98
|
+
observed behaviour (Claude 2.1.302), not a documented interface; if two
|
|
99
|
+
agents share the name, the pane keeps its tag.
|
|
100
|
+
- **Cursor** — Claude uses the terminal's real cursor, so the app does too:
|
|
101
|
+
textual-tty's painted cursor cell is off, and while a Claude pane has the
|
|
102
|
+
keys the app shows your terminal's own cursor at Claude's cursor position
|
|
103
|
+
(hidden while the list or a prompt has the keys). Textual has no public call
|
|
104
|
+
for that, so the app writes the show/hide codes itself; Textual is pinned to
|
|
105
|
+
8.2.8 for it.
|
|
106
|
+
- **New conversation (`n`)** — starts plain `claude` in the list's directory
|
|
107
|
+
(also with all directories listed). Claude writes no transcript before the
|
|
108
|
+
first message, but `claude agents` lists the new process at once with its
|
|
109
|
+
session id: the app matches the pane's process id to learn it (a poll 3 s
|
|
110
|
+
after the start, then every 10 s). Until then the mode-line says
|
|
111
|
+
`▶ new conversation`. The row appears with the first scan after the first
|
|
112
|
+
message, and the cursor goes to it.
|
|
113
|
+
- **All directories (`a`)** — every folder under `~/.claude/projects`. The
|
|
114
|
+
first read takes a few seconds (~4 s for ~1.9 GB here, in the background
|
|
115
|
+
thread; the mode-line says `reading transcripts…`, typing stays at ~35 ms).
|
|
116
|
+
A conversation from another directory starts its Claude in that directory:
|
|
117
|
+
resumed from elsewhere, Claude loads it but works in the wrong directory.
|
|
118
|
+
Claude keeps a transcript in the folder named after the real path of the
|
|
119
|
+
directory it ran in; older transcripts can record a `cwd` that does not
|
|
120
|
+
match their folder, so the app uses the recorded `cwd` when its real path
|
|
121
|
+
gives the folder name, else an existing directory found from the folder
|
|
122
|
+
name. When no such directory exists any more, `Enter` says so and opens
|
|
123
|
+
nothing. This directory's list also reads the folder of its real path (the
|
|
124
|
+
two differ for a symlinked path).
|
|
125
|
+
- **Claude exited** (`/exit`) — the pane keeps its last screen and the
|
|
126
|
+
mode-line says `(ended)`; `Enter` on the row starts it again.
|
|
127
|
+
- **Delete (`d` / `D`)** — Claude has no per-session delete (`claude rm` keeps
|
|
128
|
+
the transcript; `claude purge` deletes a whole directory). `D` refuses while
|
|
129
|
+
the conversation runs in another terminal, and when `claude agents` fails
|
|
130
|
+
(then where it runs is not known); one delete runs at a time. Otherwise it
|
|
131
|
+
runs `claude stop` +
|
|
132
|
+
`claude rm` on its background sessions (stops if Claude refuses, e.g. a
|
|
133
|
+
worktree with unpushed commits), closes its panes, waits for those processes
|
|
134
|
+
to exit, then moves every transcript of the conversation to
|
|
135
|
+
`~/.local/share/sessionmux/trash/<time>-<id>/` (`$XDG_DATA_HOME`).
|
|
136
|
+
`RESTORE.txt` there lists where each file came from; move them back to undo.
|
|
137
|
+
Empty the trash with `rm -rf ~/.local/share/sessionmux/trash`.
|
|
138
|
+
- **Open conversations across runs** — what is open (and which one is shown,
|
|
139
|
+
and `a`) is written at every change to
|
|
140
|
+
`$XDG_STATE_HOME/sessionmux/open/<encoded list dir>.json` (default
|
|
141
|
+
`~/.local/state`; the XDG spec's place for "current state of the application
|
|
142
|
+
that can be reused on a restart"; directories made with mode 0700; atomic
|
|
143
|
+
write), so it is current even if the computer dies. The next start, once the
|
|
144
|
+
first scan and poll are in, starts again only the conversation that was
|
|
145
|
+
shown (the keys stay in the list) and marks the others `○`; they start on
|
|
146
|
+
`Enter` (each Claude costs ~2 s and a few hundred MB). Not started: one that
|
|
147
|
+
runs in another terminal now. Dropped from it: a conversation you end
|
|
148
|
+
(`/exit`), close (`x`) or delete; `q` keeps them all. A new conversation is
|
|
149
|
+
kept once it has its session id. The conversations themselves never depend
|
|
150
|
+
on this file: Claude writes every message to its transcript as it goes.
|
|
151
|
+
- **Memory** — each open conversation is one running Claude until `q` or `x`.
|
|
152
|
+
|
|
153
|
+
Files (under `src/sessionmux/`):
|
|
154
|
+
|
|
155
|
+
- `sessions.py` — reads Claude's transcripts
|
|
156
|
+
(`~/.claude/projects/<encoded-cwd>/<id>.jsonl`, an internal format) into
|
|
157
|
+
conversations; joins your labels/groups/tags; finds where to start a
|
|
158
|
+
conversation's Claude. Transcripts reach hundreds of MB: only the few
|
|
159
|
+
relevant lines are parsed, the newest `cwd` is read from the file's end,
|
|
160
|
+
unchanged files come from a cache, and a file that grew is read only from
|
|
161
|
+
where the last read stopped (Claude only appends). The app scans in a
|
|
162
|
+
background thread, so the list never freezes.
|
|
163
|
+
- `agents.py` — live state from `claude agents --json` (read-only, in a
|
|
164
|
+
background thread every 10 s, after you open a conversation and after a new
|
|
165
|
+
title; one poll at a time, and asked while one runs it polls once more after
|
|
166
|
+
it, so snapshots arrive in order; each call costs ~0.4 s CPU; a failed call
|
|
167
|
+
keeps the last snapshot), the title → session matcher, and stop/rm/wait for
|
|
168
|
+
delete.
|
|
169
|
+
- `hosting.py` — `Target` (how to open a conversation) and `ClaudePane`.
|
|
170
|
+
- `state.py` — `OpenState`, the open conversations kept across runs (XDG state dir).
|
|
171
|
+
- `xdgdirs.py` — the XDG data and state directories, and making them (0700).
|
|
172
|
+
- `legacy.py` — moves the files of earlier versions to where they live now.
|
|
173
|
+
- `app.py` — the Textual app.
|
|
174
|
+
|
|
175
|
+
The app's own files follow the XDG base directories (`xdgdirs.py`; a relative
|
|
176
|
+
`$XDG_*` value is ignored, as the spec says; new directories are mode 0700):
|
|
177
|
+
|
|
178
|
+
| What | Where |
|
|
179
|
+
|---|---|
|
|
180
|
+
| Labels, groups, tags (keyed by session id) | `$XDG_DATA_HOME/sessionmux/meta.json` (`~/.local/share/...`) |
|
|
181
|
+
| Trash | `$XDG_DATA_HOME/sessionmux/trash/` |
|
|
182
|
+
| Open conversations, per list directory | `$XDG_STATE_HOME/sessionmux/open/` (`~/.local/state/...`) |
|
|
183
|
+
|
|
184
|
+
Each label change is a read-modify-write under a lock (`meta.json.lock`,
|
|
185
|
+
flock: also between two running apps) and an atomic write (temp file +
|
|
186
|
+
rename). Claude's own files under `~/.claude` are only read, and moved by
|
|
187
|
+
delete. Earlier versions kept them elsewhere: until 2026-10-18 the labels and
|
|
188
|
+
the trash in `~/.claude` (`session-switcher-meta.json`,
|
|
189
|
+
`session-switcher-trash/`), until 2026-10-19 under the old name
|
|
190
|
+
(`~/.local/share/claude-sessions/`, `~/.local/state/claude-sessions/`). The
|
|
191
|
+
app moves them once at start (`legacy.py`; a file already in the new place
|
|
192
|
+
wins; each trash folder's `RESTORE.txt` gets its new paths) and says what it
|
|
193
|
+
moved.
|
|
194
|
+
|
|
195
|
+
On quit, Textual cancels the background workers but cannot stop their
|
|
196
|
+
threads: a scan stops at the next file, and nothing a worker sends after that
|
|
197
|
+
is applied.
|
|
198
|
+
|
|
199
|
+
Crashes of the app are logged to `logs/switcher.err`.
|
|
200
|
+
|
|
201
|
+
## Tests
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
uv run pytest -q
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
- `tests/test_sessions.py` — transcript parsing (also appended and half-written lines),
|
|
208
|
+
`continued-in` chains, rows, every directory, where a conversation starts, sidecar, trash
|
|
209
|
+
(hand-written transcripts in a temporary `CLAUDE_CONFIG_DIR`).
|
|
210
|
+
- `tests/test_agents.py` — `claude agents --json` parsing (faked), the title matcher,
|
|
211
|
+
stop/rm, waiting for processes (zombies count as gone).
|
|
212
|
+
- `tests/test_app.py` — how Enter opens a row, the list width, the list UI, delete, `a`.
|
|
213
|
+
- `tests/test_state.py` — the state file: XDG paths, 0700 directories, shape, atomic write.
|
|
214
|
+
- `tests/test_legacy.py` — moving the files of earlier versions.
|
|
215
|
+
|
|
216
|
+
Every test runs with `CLAUDE_CONFIG_DIR`, `XDG_DATA_HOME` and `XDG_STATE_HOME` in
|
|
217
|
+
a temporary directory (autouse fixtures in `tests/conftest.py`), never the real ones.
|
|
218
|
+
- `tests/test_hosting.py` — the panes with a fake `claude` in a real pseudo-terminal:
|
|
219
|
+
start, switch back (same process), keys reach Claude, Alt-z/Alt-s/Tab, Ctrl+Q inside
|
|
220
|
+
Claude does not quit, `q` closes every Claude, restart after exit, ← retagging,
|
|
221
|
+
delete closes the pane, no process left behind, `n` (id from the process, the row
|
|
222
|
+
appears, the cursor follows), another directory's conversation runs there.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# docs/issues — follow-ups with an owner-shaped edge
|
|
2
|
+
|
|
3
|
+
One file per follow-up, self-contained. This folder is for work that is **known, scoped, and
|
|
4
|
+
not being done right now** — the residue a session leaves behind when it lands its main change.
|
|
5
|
+
Without a home, "we should fix X" decays into a chat log nobody re-reads.
|
|
6
|
+
|
|
7
|
+
**The files are the store.** An issue is markdown in git, readable and editable by hand. Anything
|
|
8
|
+
derived (an index) is generated from the files and is never authoritative.
|
|
9
|
+
|
|
10
|
+
## The rules that make one worth writing
|
|
11
|
+
|
|
12
|
+
1. **Answerable cold.** Assume the reader has none of the author's context. Concrete instances,
|
|
13
|
+
`file:line`, the actual commands.
|
|
14
|
+
2. **Say what is MEASURED and what is INFERRED**, separately and in those words.
|
|
15
|
+
3. **Define done** — the specific observation that will be true afterwards, and how to make it.
|
|
16
|
+
4. **Name what you deliberately did NOT do,** with the reason.
|
|
17
|
+
5. **Close by marking it done,** naming the commit that landed the fix.
|
|
18
|
+
|
|
19
|
+
## Open
|
|
20
|
+
|
|
21
|
+
_None yet._
|