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.
@@ -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.
@@ -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._