reclaude 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,26 @@
1
+ name: publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v5
14
+ - run: uv run --group dev pytest -q
15
+
16
+ publish:
17
+ needs: test
18
+ runs-on: ubuntu-latest
19
+ environment: pypi
20
+ permissions:
21
+ id-token: write
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ - uses: astral-sh/setup-uv@v5
25
+ - run: uv build
26
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ dist/
@@ -0,0 +1,51 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What this is
6
+
7
+ `reclaude` is a stdlib-only Python curses picker for resuming Claude Code sessions. It reads `~/.claude/history.jsonl`, shows recent project directories as an expandable tree (sessions inline), locks directories that already have a running claude session, can resurrect sessions from deleted git worktrees, then chdirs and execs `claude`. Packaged for PyPI; installed with `uv tool install reclaude` (or `uv tool install --editable .` for hacking), which puts the `reclaude` console script on `PATH`.
8
+
9
+ ## Commands
10
+
11
+ ```bash
12
+ python3 -m pytest -q # run all tests (from repo root)
13
+ python3 -m pytest tests/test_reclaude.py::test_mung_path -v # single test
14
+ uv run --group dev pytest -q # tests in an isolated env (as CI does)
15
+ uv build # build sdist + wheel into dist/
16
+ reclaude # run (needs a real TTY)
17
+ ```
18
+
19
+ No third-party runtime dependencies (Python 3.10+ stdlib; pytest for tests). `[tool.pytest.ini_options] pythonpath = ["src"]` in `pyproject.toml` lets `python3 -m pytest` import the package from the repo root without an install. Releases publish to PyPI via `.github/workflows/publish.yml` (trusted publishing) on a `v*` tag push.
20
+
21
+ ## Architecture
22
+
23
+ `reclaude` is a `src/` package (`src/reclaude/`): `__init__.py` holds all the logic, deliberately split into two halves (it can be broken into separate modules — e.g. pure `core.py` + curses `tui.py` — without touching packaging); `__main__.py` wires up `python -m reclaude`. The two halves of `__init__.py`:
24
+
25
+ 1. **Pure functions (top half, unit-tested):** `parse_history` → `group_by_home` builds per-directory groups of sessions; `classify_dir` tags each dir live / orphan-worktree / gone; `live_sessions` finds busy dirs + running session ids; `flatten_rows` turns groups + UI state (expansion, typed filter, age window, missing toggle) into the visible row list; `_row_spans` renders a row as `(text, colorkey)` spans. All filesystem/proc access is injectable (`isdir=`, `transcript_exists=`, `proc_root=`, `sessions_dir=`) so tests are hermetic — keep it that way.
26
+ 2. **Curses layer (bottom half, no unit tests):** `init_colors` maps colorkeys → curses attrs (monochrome fallback), `_draw` renders span rows, `run_picker` is the event loop returning a launch tuple, `main` execs claude. Verified via throwaway fake-stdscr harnesses (scripted `getch`, recorded `addnstr`) in /tmp — never committed — plus manual testing by Bryce.
27
+
28
+ Data flow: `history.jsonl` → entries → groups (sessions attributed to their **home** dir) → rows → spans → screen; picker returns `("resume", path, id)` or `("worktree", repo, name, id)` → `os.chdir` + `os.execvp`.
29
+
30
+ ### Invariants worth protecting
31
+
32
+ - **Display = action.** A dir row shows the time/prompt of `vis_sessions[0]` and Enter resumes exactly that session id. Never reintroduce `claude --continue` — it can disagree with what's displayed.
33
+ - **Filters before the MAX_DIRS cap** in `flatten_rows`, so hiding noise surfaces older live dirs.
34
+ - **Printable keys feed the incremental filter.** New shortcuts must be control keys (Ctrl-W=23 toggles missing dirs, Ctrl-T=20 cycles the age window); `q` quits only when the filter is empty.
35
+ - `COLOR_KEYS` must cover every key `_row_spans` emits.
36
+ - All `addnstr` calls write at most `maxx - 1` columns and are wrapped in `try/except curses.error` (tiny terminals, bottom-right quirk).
37
+
38
+ ## Empirically verified Claude Code facts (the whole design rests on these)
39
+
40
+ - `claude --resume <id>` / `--continue` only find transcripts under `~/.claude/projects/<munged-cwd>/` for the **current** directory. Munging: `/` and `.` → `-` (deterministic; un-munging is ambiguous — only ever mung).
41
+ - A session's transcript lives under the directory the session **started** in and never moves, even if the session later changed cwd (EnterWorktree etc.). Hence `group_by_home`: first project in history = the only resumable location.
42
+ - Deleted worktree sessions resurrect via `cd <repo> && claude --worktree <name> --resume <id>` — claude recreates `<repo>/.claude/worktrees/<name>` (branch `worktree-<name>`, base per `worktree.baseRef`) and finds the transcript because the path matches again.
43
+ - `~/.claude/sessions/<pid>.json` describes live claude processes (`{pid, sessionId, cwd, ...}`). Stale files survive crashes — always validate `/proc/<pid>/comm == "claude"` before trusting one.
44
+ - `history.jsonl` lines: `{"display", "pastedContents", "timestamp"(ms), "project", "sessionId"}`; `display` can contain newlines/tabs (flattened in `parse_history`).
45
+
46
+ If claude changes any of this, re-verify empirically (cheap probe: `claude --resume <id> --fork-session --model haiku --print "Reply with only the word ok"` from the directory under test; clean up the forked transcript afterwards).
47
+
48
+ ## Workflow conventions
49
+
50
+ - TDD for pure functions; the curses layer changes get fake-stdscr smoke tests instead.
51
+ - Commits are conventional-commit style (`feat:`, `fix:`, `chore:`, `polish:`). No standalone `docs:` or `test:` commits — documentation and test changes ride along in the feature or bugfix commit they belong to.
reclaude-0.1.0/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026 Bryce Boe
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: reclaude
3
+ Version: 0.1.0
4
+ Summary: Curses picker for resuming Claude Code sessions
5
+ Project-URL: Homepage, https://github.com/bboe/reclaude
6
+ Project-URL: Repository, https://github.com/bboe/reclaude
7
+ Project-URL: Issues, https://github.com/bboe/reclaude/issues
8
+ Author-email: Bryce Boe <bbzbryce@gmail.com>
9
+ License-Expression: BSD-2-Clause
10
+ License-File: LICENSE
11
+ Keywords: claude,claude-code,curses,resume,tui
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console :: Curses
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: POSIX
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Utilities
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+
24
+ # reclaude
25
+
26
+ A keyboard-driven curses picker for resuming [Claude Code](https://claude.com/claude-code)
27
+ sessions. It reads `~/.claude/history.jsonl` and shows your recent project directories as an
28
+ expandable tree with sessions inline, so you can jump back into a conversation without
29
+ remembering its session id.
30
+
31
+ - **Tree view** of recent directories; expand one to see its individual sessions, each shown
32
+ with its time and opening prompt.
33
+ - **Live-session locks** — directories with a running `claude` process are marked so you don't
34
+ collide with an active session.
35
+ - **Worktree resurrection** — sessions from deleted git worktrees can be brought back; reclaude
36
+ re-runs them via `claude --worktree`, which recreates the worktree and finds the transcript.
37
+ - On selection it `chdir`s into the directory and `exec`s `claude --resume <id>` (display always
38
+ matches the action — the row you see is the session you get).
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ uv tool install reclaude # recommended
44
+ pipx install reclaude
45
+ pip install reclaude
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ ```bash
51
+ reclaude # or: python -m reclaude
52
+ ```
53
+
54
+ | Key | Action |
55
+ | -------------- | ------------------------------------------------- |
56
+ | `↑` / `↓` | Move selection |
57
+ | `Enter` | Expand a directory, or resume the selected session |
58
+ | *type* | Incrementally filter by directory / prompt text |
59
+ | `Ctrl-W` | Toggle showing directories whose path is gone |
60
+ | `Ctrl-T` | Cycle the age window (how far back to look) |
61
+ | `Backspace` | Delete a filter character |
62
+ | `q` / `Esc` | Quit (`q` quits only when the filter is empty) |
63
+
64
+ ## Requirements
65
+
66
+ - A POSIX system with an interactive terminal (TTY).
67
+ - [Claude Code](https://claude.com/claude-code) installed and on your `PATH`.
68
+ - Python 3.10+. No third-party dependencies (Python standard library only).
69
+
70
+ ## License
71
+
72
+ BSD-2-Clause. See [LICENSE](LICENSE).
@@ -0,0 +1,49 @@
1
+ # reclaude
2
+
3
+ A keyboard-driven curses picker for resuming [Claude Code](https://claude.com/claude-code)
4
+ sessions. It reads `~/.claude/history.jsonl` and shows your recent project directories as an
5
+ expandable tree with sessions inline, so you can jump back into a conversation without
6
+ remembering its session id.
7
+
8
+ - **Tree view** of recent directories; expand one to see its individual sessions, each shown
9
+ with its time and opening prompt.
10
+ - **Live-session locks** — directories with a running `claude` process are marked so you don't
11
+ collide with an active session.
12
+ - **Worktree resurrection** — sessions from deleted git worktrees can be brought back; reclaude
13
+ re-runs them via `claude --worktree`, which recreates the worktree and finds the transcript.
14
+ - On selection it `chdir`s into the directory and `exec`s `claude --resume <id>` (display always
15
+ matches the action — the row you see is the session you get).
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ uv tool install reclaude # recommended
21
+ pipx install reclaude
22
+ pip install reclaude
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```bash
28
+ reclaude # or: python -m reclaude
29
+ ```
30
+
31
+ | Key | Action |
32
+ | -------------- | ------------------------------------------------- |
33
+ | `↑` / `↓` | Move selection |
34
+ | `Enter` | Expand a directory, or resume the selected session |
35
+ | *type* | Incrementally filter by directory / prompt text |
36
+ | `Ctrl-W` | Toggle showing directories whose path is gone |
37
+ | `Ctrl-T` | Cycle the age window (how far back to look) |
38
+ | `Backspace` | Delete a filter character |
39
+ | `q` / `Esc` | Quit (`q` quits only when the filter is empty) |
40
+
41
+ ## Requirements
42
+
43
+ - A POSIX system with an interactive terminal (TTY).
44
+ - [Claude Code](https://claude.com/claude-code) installed and on your `PATH`.
45
+ - Python 3.10+. No third-party dependencies (Python standard library only).
46
+
47
+ ## License
48
+
49
+ BSD-2-Clause. See [LICENSE](LICENSE).
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "reclaude"
7
+ version = "0.1.0"
8
+ description = "Curses picker for resuming Claude Code sessions"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "BSD-2-Clause"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Bryce Boe", email = "bbzbryce@gmail.com" }]
14
+ keywords = ["claude", "claude-code", "curses", "tui", "resume"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console :: Curses",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: POSIX",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Utilities",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/bboe/reclaude"
29
+ Repository = "https://github.com/bboe/reclaude"
30
+ Issues = "https://github.com/bboe/reclaude/issues"
31
+
32
+ [project.scripts]
33
+ reclaude = "reclaude:main"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/reclaude"]
37
+
38
+ [dependency-groups]
39
+ dev = ["pytest"]
40
+
41
+ [tool.pytest.ini_options]
42
+ pythonpath = ["src"]
@@ -0,0 +1,476 @@
1
+ """reclaude: curses picker for recent Claude Code sessions.
2
+
3
+ Reads ~/.claude/history.jsonl, shows recent project directories as an
4
+ expandable tree (sessions inline under each directory), marks directories
5
+ with a running claude session as locked, resurrects sessions from deleted
6
+ git worktrees via `claude --worktree`, then chdirs and execs claude.
7
+ """
8
+ import curses
9
+ import json
10
+ import os
11
+ import re
12
+ import sys
13
+ import time
14
+
15
+ HISTORY_PATH = os.path.expanduser("~/.claude/history.jsonl")
16
+ MAX_DIRS = 30
17
+ PROJECTS_DIR = os.path.expanduser("~/.claude/projects")
18
+ SESSIONS_DIR = os.path.expanduser("~/.claude/sessions")
19
+
20
+
21
+ def parse_history(lines):
22
+ """Parse history.jsonl lines into entry dicts, skipping malformed lines."""
23
+ entries = []
24
+ for line in lines:
25
+ try:
26
+ obj = json.loads(line)
27
+ except (json.JSONDecodeError, ValueError):
28
+ continue
29
+ if not isinstance(obj, dict):
30
+ continue
31
+ project = obj.get("project")
32
+ session_id = obj.get("sessionId")
33
+ ts = obj.get("timestamp")
34
+ display = obj.get("display")
35
+ if not (isinstance(project, str) and isinstance(session_id, str)
36
+ and isinstance(ts, (int, float)) and not isinstance(ts, bool)):
37
+ continue
38
+ if isinstance(display, str):
39
+ display = " ".join(display.split())
40
+ else:
41
+ display = ""
42
+ entries.append({
43
+ "project": project,
44
+ "session_id": session_id,
45
+ "ts": ts,
46
+ "display": display,
47
+ })
48
+ return entries
49
+
50
+
51
+ def mung_path(path):
52
+ """Munged ~/.claude/projects dir name for a path: '/' and '.' become '-'."""
53
+ return path.replace("/", "-").replace(".", "-")
54
+
55
+
56
+ def transcript_path(home_dir, session_id, projects_dir=None):
57
+ return os.path.join(projects_dir or PROJECTS_DIR,
58
+ mung_path(home_dir), session_id + ".jsonl")
59
+
60
+
61
+ def transcript_exists(home_dir, session_id, projects_dir=None):
62
+ return os.path.isfile(transcript_path(home_dir, session_id, projects_dir))
63
+
64
+
65
+ def group_by_home(entries, transcript_exists=transcript_exists):
66
+ """Group sessions under their home dir (first project seen), newest first.
67
+
68
+ A session's transcript lives where the session started, so that first
69
+ directory is the only place `claude --resume` can find it. Sessions whose
70
+ transcript no longer exists are dropped. Each group:
71
+ {"path", "last_ts", "sessions": [{"session_id", "ts", "display"}, ...]}
72
+ with sessions newest-first.
73
+ """
74
+ sessions = {}
75
+ for e in entries:
76
+ s = sessions.setdefault(e["session_id"],
77
+ {"home": e["project"], "ts": 0, "display": ""})
78
+ if e["ts"] >= s["ts"]:
79
+ s["ts"] = e["ts"]
80
+ s["display"] = e["display"]
81
+ dirs = {}
82
+ for sid, s in sessions.items():
83
+ if not transcript_exists(s["home"], sid):
84
+ continue
85
+ dirs.setdefault(s["home"], []).append(
86
+ {"session_id": sid, "ts": s["ts"], "display": s["display"]})
87
+ groups = []
88
+ for path, sess in dirs.items():
89
+ sess.sort(key=lambda s: -s["ts"])
90
+ groups.append({"path": path, "last_ts": sess[0]["ts"], "sessions": sess})
91
+ groups.sort(key=lambda g: -g["last_ts"])
92
+ return groups
93
+
94
+
95
+ WORKTREE_RE = re.compile(r"^(?P<repo>.+)/\.claude/worktrees/(?P<name>[^/]+)$")
96
+
97
+
98
+ def classify_dir(path, isdir=os.path.isdir):
99
+ """Classify a session home dir.
100
+
101
+ Returns (kind, repo, name): kind is "live" (dir exists), "orphan-worktree"
102
+ (dir gone but it was <repo>/.claude/worktrees/<name> and <repo> exists —
103
+ resumable via `claude --worktree <name> --resume <id>` from <repo>), or
104
+ "gone". repo/name are None unless kind == "orphan-worktree".
105
+ """
106
+ if isdir(path):
107
+ return ("live", None, None)
108
+ m = WORKTREE_RE.match(path)
109
+ if m and isdir(m.group("repo")):
110
+ return ("orphan-worktree", m.group("repo"), m.group("name"))
111
+ return ("gone", None, None)
112
+
113
+
114
+ def find_busy_dirs(proc_root="/proc"):
115
+ """Return the set of realpath cwds of running `claude` processes."""
116
+ busy = set()
117
+ try:
118
+ names = os.listdir(proc_root)
119
+ except OSError:
120
+ return busy
121
+ for name in names:
122
+ if not name.isdigit():
123
+ continue
124
+ base = os.path.join(proc_root, name)
125
+ try:
126
+ with open(os.path.join(base, "comm")) as f:
127
+ if f.read().strip() != "claude":
128
+ continue
129
+ busy.add(os.path.realpath(os.path.join(base, "cwd")))
130
+ except OSError:
131
+ continue # process exited, or not ours to read
132
+ return busy
133
+
134
+
135
+ def live_sessions(sessions_dir=None, proc_root="/proc"):
136
+ """Busy dirs and running session ids from ~/.claude/sessions records.
137
+
138
+ Each <pid>.json record counts only if /proc/<pid>/comm is "claude" (stale
139
+ files survive crashes). Falls back to scanning /proc when the records
140
+ yield nothing, so a claude started outside the session tracker still
141
+ locks its directory (running ids unknown in that case).
142
+ """
143
+ busy, running = set(), set()
144
+ try:
145
+ names = os.listdir(sessions_dir or SESSIONS_DIR)
146
+ except OSError:
147
+ names = []
148
+ for name in names:
149
+ if not name.endswith(".json"):
150
+ continue
151
+ try:
152
+ with open(os.path.join(sessions_dir or SESSIONS_DIR, name)) as f:
153
+ rec = json.load(f)
154
+ except (OSError, ValueError):
155
+ continue
156
+ if not isinstance(rec, dict):
157
+ continue
158
+ pid, cwd, sid = rec.get("pid"), rec.get("cwd"), rec.get("sessionId")
159
+ if not (isinstance(pid, int) and isinstance(cwd, str)):
160
+ continue
161
+ try:
162
+ with open(os.path.join(proc_root, str(pid), "comm")) as f:
163
+ if f.read().strip() != "claude":
164
+ continue
165
+ except OSError:
166
+ continue
167
+ busy.add(os.path.realpath(cwd))
168
+ if isinstance(sid, str):
169
+ running.add(sid)
170
+ if not busy:
171
+ busy = find_busy_dirs(proc_root)
172
+ return busy, running
173
+
174
+
175
+ def relative_time(ts_ms, now_ms):
176
+ """Compact age like '5s', '3m', '7h', '2d'."""
177
+ secs = max(0, int((now_ms - ts_ms) / 1000))
178
+ if secs < 60:
179
+ return f"{secs}s"
180
+ if secs < 3600:
181
+ return f"{secs // 60}m"
182
+ if secs < 86400:
183
+ return f"{secs // 3600}h"
184
+ return f"{secs // 86400}d"
185
+
186
+
187
+ def abbreviate_path(path, home):
188
+ if path == home:
189
+ return "~"
190
+ if path.startswith(home + os.sep):
191
+ return "~" + path[len(home):]
192
+ return path
193
+
194
+
195
+ def truncate(s, width):
196
+ if width <= 0:
197
+ return ""
198
+ if len(s) <= width:
199
+ return s
200
+ return s[: width - 1] + "…"
201
+
202
+
203
+ def _is_busy(path, busy):
204
+ return os.path.realpath(path) in busy
205
+
206
+
207
+ def flatten_rows(groups, expanded, filt, home, busy, running_ids,
208
+ isdir=os.path.isdir, show_missing=True, min_ts=None):
209
+ """Flatten groups + expansion state into the visible row list.
210
+
211
+ Dir row: {"kind": "dir", "group", "vis_sessions", "cls", "repo",
212
+ "name", "busy"}
213
+ Session row: {"kind": "session", "group", "session", "cls", "repo",
214
+ "name", "busy", "running"}.
215
+ A session is visible iff it passes the age window (min_ts) and the text
216
+ filter — a dir-path match admits all its sessions, otherwise the prompt
217
+ text must contain the filter (both case-insensitive). A dir is shown iff
218
+ it has visible sessions and passes the missing-dir filter; dirs are
219
+ capped at MAX_DIRS. Expanded dirs render only their visible sessions.
220
+ """
221
+ f = filt.lower()
222
+ kept = []
223
+ for g in groups:
224
+ path_match = f in abbreviate_path(g["path"], home).lower()
225
+ vis = [s for s in g["sessions"]
226
+ if (min_ts is None or s["ts"] >= min_ts)
227
+ and (path_match or f in s["display"].lower())]
228
+ if not vis:
229
+ continue
230
+ cls, repo, name = classify_dir(g["path"], isdir)
231
+ if not show_missing and cls != "live":
232
+ continue
233
+ kept.append((g, vis, cls, repo, name))
234
+ rows = []
235
+ for g, vis, cls, repo, name in kept[:MAX_DIRS]:
236
+ b = _is_busy(g["path"], busy)
237
+ rows.append({"kind": "dir", "group": g, "vis_sessions": vis,
238
+ "cls": cls, "repo": repo, "name": name, "busy": b})
239
+ if g["path"] in expanded:
240
+ for s in vis:
241
+ rows.append({"kind": "session", "group": g, "session": s,
242
+ "cls": cls, "repo": repo, "name": name, "busy": b,
243
+ "running": s["session_id"] in running_ids})
244
+ return rows
245
+
246
+
247
+ # COLOR_KEYS must cover every key that _row_spans emits.
248
+ COLOR_KEYS = ("time", "path", "running", "orphan", "gone", "flash", "text")
249
+
250
+
251
+ def init_colors():
252
+ """Map color keys to curses attributes; monochrome fallback."""
253
+ attrs = {k: curses.A_NORMAL for k in COLOR_KEYS}
254
+ attrs["path"] = curses.A_BOLD
255
+ attrs["gone"] = curses.A_DIM
256
+ try:
257
+ curses.start_color()
258
+ curses.use_default_colors()
259
+ if not curses.has_colors():
260
+ return attrs
261
+ for i, (key, color) in enumerate([
262
+ ("time", curses.COLOR_CYAN),
263
+ ("running", curses.COLOR_YELLOW),
264
+ ("orphan", curses.COLOR_MAGENTA),
265
+ ("flash", curses.COLOR_RED)], start=1):
266
+ curses.init_pair(i, color, -1)
267
+ attrs[key] = curses.color_pair(i)
268
+ except curses.error:
269
+ pass
270
+ return attrs
271
+
272
+
273
+ def _draw(stdscr, title, render_rows, sel, top, footer, footer_attr, attrs):
274
+ """render_rows: list of (spans, extra_attr). Returns new scroll `top`."""
275
+ stdscr.erase()
276
+ maxy, maxx = stdscr.getmaxyx()
277
+ # maxy==1: header only; maxy==2: header (y=0) + footer (y=1), no body rows.
278
+ body = max(0, maxy - 2)
279
+ if sel < top:
280
+ top = sel
281
+ elif body > 0 and sel >= top + body:
282
+ top = sel - body + 1
283
+ try:
284
+ stdscr.addnstr(0, 0, truncate(title, maxx - 1), maxx - 1, curses.A_BOLD)
285
+ except curses.error:
286
+ pass
287
+ for i, (spans, extra) in enumerate(render_rows[top:top + body]):
288
+ row_attr = extra | (curses.A_REVERSE if top + i == sel else 0)
289
+ x = 0
290
+ for text, key in spans:
291
+ avail = maxx - 1 - x
292
+ if avail <= 0:
293
+ break
294
+ t = truncate(text, avail)
295
+ try:
296
+ stdscr.addnstr(1 + i, x, t, avail, attrs.get(key, curses.A_NORMAL) | row_attr)
297
+ except curses.error:
298
+ pass
299
+ x += len(t)
300
+ if x < maxx - 1: # pad so the selection bar spans the line
301
+ try:
302
+ stdscr.addnstr(1 + i, x, " " * (maxx - 1 - x), maxx - 1 - x,
303
+ row_attr)
304
+ except curses.error:
305
+ pass
306
+ if maxy >= 2:
307
+ try:
308
+ stdscr.addnstr(maxy - 1, 0, truncate(footer, maxx - 1), maxx - 1,
309
+ footer_attr)
310
+ except curses.error:
311
+ pass
312
+ stdscr.refresh()
313
+ return top
314
+
315
+
316
+ def _row_spans(row, now_ms, home):
317
+ """Render a flatten_rows row as [(text, colorkey)] spans.
318
+
319
+ Color keys: "time", "path", "running", "orphan", "gone", "text" — mapped
320
+ to curses attributes by init_colors().
321
+ """
322
+ if row["kind"] == "dir":
323
+ g = row["group"]
324
+ vis = row["vis_sessions"]
325
+ ts = vis[0]["ts"] if vis else g["last_ts"]
326
+ spans = [(f"{relative_time(ts, now_ms):>4} ", "time"),
327
+ (abbreviate_path(g["path"], home), "path")]
328
+ if row["busy"]:
329
+ spans.append((" [running]", "running"))
330
+ if row["cls"] == "orphan-worktree":
331
+ spans.append((" [worktree gone]", "orphan"))
332
+ elif row["cls"] == "gone":
333
+ spans.append((" [gone]", "gone"))
334
+ last = row["vis_sessions"][0]["display"] if row["vis_sessions"] else ""
335
+ if last:
336
+ spans.append((f" — {last}", "text"))
337
+ return spans
338
+ s = row["session"]
339
+ spans = [(" ", "text"),
340
+ (f"{relative_time(s['ts'], now_ms):>4} ", "time"),
341
+ (s["display"] or "(no prompt)", "text")]
342
+ if row.get("running"):
343
+ spans.append((" [running]", "running"))
344
+ return spans
345
+
346
+
347
+ AGE_WINDOWS = [("all", None), ("1mo", 30 * 86400_000), ("1w", 7 * 86400_000),
348
+ ("1d", 86400_000), ("1h", 3600_000)]
349
+ HELP = ("↑↓ move · ⏎ resume · →/⇥ expand · ← collapse · ^W missing · "
350
+ "^T age · type to filter · q quit")
351
+ FLASH_BUSY = "that directory already has a claude session running"
352
+ FLASH_GONE = "directory no longer exists"
353
+
354
+
355
+ def _launch(row, session_id):
356
+ if row["cls"] == "orphan-worktree":
357
+ return ("worktree", row["repo"], row["name"], session_id)
358
+ return ("resume", row["group"]["path"], session_id)
359
+
360
+
361
+ def run_picker(stdscr, groups, busy, running_ids):
362
+ """Returns ('resume', path, id) | ('worktree', repo, name, id) | None."""
363
+ try:
364
+ curses.curs_set(0)
365
+ except curses.error:
366
+ pass
367
+ stdscr.keypad(True)
368
+ attrs = init_colors()
369
+ home = os.path.expanduser("~")
370
+
371
+ sel, top, filt, flash = 0, 0, "", ""
372
+ show_missing, age_idx = True, 0
373
+ expanded = set()
374
+
375
+ while True:
376
+ now_ms = int(time.time() * 1000)
377
+ label, window = AGE_WINDOWS[age_idx]
378
+ min_ts = now_ms - window if window is not None else None
379
+ rows = flatten_rows(groups, expanded, filt, home, busy, running_ids,
380
+ show_missing=show_missing, min_ts=min_ts)
381
+ render = [(_row_spans(r, now_ms, home),
382
+ curses.A_DIM if (r["busy"] or r["cls"] == "gone") else 0)
383
+ for r in rows]
384
+ if flash:
385
+ footer, footer_attr, flash = flash, attrs["flash"], ""
386
+ elif filt:
387
+ footer, footer_attr = f"filter: {filt}▏", curses.A_DIM
388
+ else:
389
+ footer, footer_attr = HELP, curses.A_DIM
390
+ n = len(rows)
391
+ sel = max(0, min(sel, n - 1)) if n else 0
392
+ title = "reclaude — recent sessions"
393
+ if window is not None:
394
+ title += f" · ≤{label}"
395
+ if not show_missing:
396
+ title += " · missing hidden"
397
+ top = _draw(stdscr, title, render, sel, top, footer, footer_attr, attrs)
398
+
399
+ key = stdscr.getch()
400
+ if key == curses.KEY_UP:
401
+ sel = max(0, sel - 1)
402
+ elif key == curses.KEY_DOWN:
403
+ sel = min(n - 1, sel + 1) if n else 0
404
+ elif key in (curses.KEY_ENTER, 10, 13) and n:
405
+ r = rows[sel]
406
+ if r["busy"]:
407
+ flash = FLASH_BUSY
408
+ elif r["cls"] == "gone":
409
+ flash = FLASH_GONE
410
+ elif r["kind"] == "session":
411
+ return _launch(r, r["session"]["session_id"])
412
+ else:
413
+ return _launch(r, r["vis_sessions"][0]["session_id"])
414
+ elif key in (curses.KEY_RIGHT, ord("\t")) and n:
415
+ if rows[sel]["kind"] == "dir":
416
+ expanded.add(rows[sel]["group"]["path"])
417
+ elif key == curses.KEY_LEFT and n:
418
+ r = rows[sel]
419
+ if r["kind"] == "session":
420
+ for i in range(sel - 1, -1, -1):
421
+ if rows[i]["kind"] == "dir" and rows[i]["group"] is r["group"]:
422
+ sel = i
423
+ break
424
+ else:
425
+ expanded.discard(r["group"]["path"])
426
+ elif key == 27: # Esc
427
+ if filt:
428
+ filt, sel, top = "", 0, 0
429
+ else:
430
+ return None
431
+ elif key in (curses.KEY_BACKSPACE, 127, 8):
432
+ if filt:
433
+ filt, sel, top = filt[:-1], 0, 0
434
+ elif key == 23: # Ctrl-W: toggle missing dirs
435
+ show_missing = not show_missing
436
+ sel, top = 0, 0
437
+ elif key == 20: # Ctrl-T: cycle age filter
438
+ age_idx = (age_idx + 1) % len(AGE_WINDOWS)
439
+ sel, top = 0, 0
440
+ elif 32 <= key < 127:
441
+ ch = chr(key)
442
+ if ch == "q" and not filt:
443
+ return None
444
+ filt, sel, top = filt + ch, 0, 0
445
+
446
+
447
+ def main():
448
+ try:
449
+ with open(HISTORY_PATH, encoding="utf-8") as f:
450
+ entries = parse_history(f)
451
+ except OSError as e:
452
+ sys.exit(f"reclaude: cannot read {HISTORY_PATH}: {e}")
453
+ groups = group_by_home(entries)
454
+ if not groups:
455
+ sys.exit("reclaude: no resumable sessions found in history")
456
+ busy, running_ids = live_sessions()
457
+ if not sys.stdout.isatty():
458
+ sys.exit("reclaude: needs an interactive terminal")
459
+ os.environ.setdefault("ESCDELAY", "25")
460
+ result = curses.wrapper(run_picker, groups, busy, running_ids)
461
+ if result is None:
462
+ return
463
+ if result[0] == "worktree":
464
+ _, path, name, session_id = result
465
+ argv = ["claude", "--worktree", name, "--resume", session_id]
466
+ else:
467
+ _, path, session_id = result
468
+ argv = ["claude", "--resume", session_id]
469
+ try:
470
+ os.chdir(path)
471
+ except OSError as e:
472
+ sys.exit(f"reclaude: cannot chdir to {path}: {e}")
473
+ try:
474
+ os.execvp("claude", argv)
475
+ except OSError as e:
476
+ sys.exit(f"reclaude: cannot exec claude: {e}")
@@ -0,0 +1,4 @@
1
+ from reclaude import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,300 @@
1
+ import json
2
+ import os
3
+
4
+ import reclaude as cr
5
+
6
+
7
+ def test_parse_history_basic():
8
+ lines = [
9
+ '{"display":"fix bug","pastedContents":{},"timestamp":1000,"project":"/p/a","sessionId":"s1"}',
10
+ '{"display":"add feat","timestamp":2000,"project":"/p/b","sessionId":"s2"}',
11
+ ]
12
+ entries = cr.parse_history(lines)
13
+ assert entries == [
14
+ {"project": "/p/a", "session_id": "s1", "ts": 1000, "display": "fix bug"},
15
+ {"project": "/p/b", "session_id": "s2", "ts": 2000, "display": "add feat"},
16
+ ]
17
+
18
+
19
+ def test_parse_history_skips_garbage():
20
+ lines = [
21
+ "not json at all",
22
+ '{"display":"missing fields"}',
23
+ '"a json string, not an object"',
24
+ '{"display":null,"timestamp":3000,"project":"/p/c","sessionId":"s3"}',
25
+ "",
26
+ ]
27
+ entries = cr.parse_history(lines)
28
+ # Only the entry with all required fields survives; null display becomes ""
29
+ assert entries == [{"project": "/p/c", "session_id": "s3", "ts": 3000, "display": ""}]
30
+
31
+
32
+ def _e(project, sid, ts, display=""):
33
+ return {"project": project, "session_id": sid, "ts": ts, "display": display}
34
+
35
+
36
+ def test_group_by_home_attribution_and_order():
37
+ entries = [
38
+ _e("/p/a", "s1", 1000, "first"),
39
+ _e("/p/b", "s1", 5000, "moved"), # session moved dirs: home stays /p/a
40
+ _e("/p/a", "s2", 3000, "second"),
41
+ _e("/p/c", "s3", 9000, "newest"),
42
+ ]
43
+ groups = cr.group_by_home(entries, transcript_exists=lambda h, s: True)
44
+ assert [g["path"] for g in groups] == ["/p/c", "/p/a"]
45
+ a = groups[1]
46
+ assert a["last_ts"] == 5000
47
+ assert [s["session_id"] for s in a["sessions"]] == ["s1", "s2"]
48
+ assert a["sessions"][0]["display"] == "moved"
49
+
50
+
51
+ def test_group_by_home_drops_sessions_without_transcript():
52
+ entries = [_e("/p/a", "s1", 1000), _e("/p/a", "s2", 2000), _e("/p/b", "s3", 3000)]
53
+ groups = cr.group_by_home(entries, transcript_exists=lambda h, sid: sid != "s2")
54
+ assert [g["path"] for g in groups] == ["/p/b", "/p/a"]
55
+ assert [s["session_id"] for s in groups[1]["sessions"]] == ["s1"]
56
+
57
+
58
+ def test_group_by_home_drops_empty_groups():
59
+ entries = [_e("/p/a", "s1", 1000)]
60
+ assert cr.group_by_home(entries, transcript_exists=lambda h, s: False) == []
61
+
62
+
63
+ def _fake_proc(tmp_path, pid, comm, cwd_target):
64
+ p = tmp_path / str(pid)
65
+ p.mkdir()
66
+ (p / "comm").write_text(comm + "\n")
67
+ (p / "cwd").symlink_to(cwd_target)
68
+
69
+
70
+ def test_find_busy_dirs(tmp_path):
71
+ work = tmp_path / "work"
72
+ work.mkdir()
73
+ other = tmp_path / "other"
74
+ other.mkdir()
75
+ _fake_proc(tmp_path, 100, "claude", work)
76
+ _fake_proc(tmp_path, 200, "vim", other) # wrong comm: ignored
77
+ (tmp_path / "self").mkdir() # non-numeric entry: ignored
78
+ broken = tmp_path / "300"
79
+ broken.mkdir() # numeric but no comm file: ignored
80
+ busy = cr.find_busy_dirs(proc_root=str(tmp_path))
81
+ assert busy == {os.path.realpath(str(work))}
82
+
83
+
84
+ def test_find_busy_dirs_missing_proc_root():
85
+ assert cr.find_busy_dirs(proc_root="/nonexistent-proc") == set()
86
+
87
+
88
+ def test_relative_time():
89
+ now = 10_000_000_000_000
90
+ assert cr.relative_time(now - 5_000, now) == "5s"
91
+ assert cr.relative_time(now - 90_000, now) == "1m"
92
+ assert cr.relative_time(now - 3 * 3600_000, now) == "3h"
93
+ assert cr.relative_time(now - 49 * 3600_000, now) == "2d"
94
+ assert cr.relative_time(now + 5_000, now) == "0s" # clock skew clamps to 0
95
+
96
+
97
+ def test_abbreviate_path():
98
+ assert cr.abbreviate_path("/home/u/proj", "/home/u") == "~/proj"
99
+ assert cr.abbreviate_path("/home/u", "/home/u") == "~"
100
+ assert cr.abbreviate_path("/home/uother/x", "/home/u") == "/home/uother/x"
101
+ assert cr.abbreviate_path("/etc/x", "/home/u") == "/etc/x"
102
+
103
+
104
+ def test_parse_history_flattens_control_chars():
105
+ lines = ['{"display":"line one\\nline two\\tend","timestamp":1,"project":"/p","sessionId":"s"}']
106
+ assert cr.parse_history(lines)[0]["display"] == "line one line two end"
107
+
108
+
109
+ def test_truncate():
110
+ assert cr.truncate("hello", 10) == "hello"
111
+ assert cr.truncate("hello world", 8) == "hello w…"
112
+ assert cr.truncate("hi", 0) == ""
113
+
114
+
115
+ def test_mung_path():
116
+ assert cr.mung_path("/home/u/scratch") == "-home-u-scratch"
117
+ assert cr.mung_path("/home/u/repo/.claude/worktrees/a1") == \
118
+ "-home-u-repo--claude-worktrees-a1"
119
+
120
+
121
+ def test_transcript_path():
122
+ p = cr.transcript_path("/home/u/scratch", "abc", projects_dir="/pp")
123
+ assert p == "/pp/-home-u-scratch/abc.jsonl"
124
+
125
+
126
+ def test_transcript_exists(tmp_path):
127
+ d = tmp_path / "-home-u-x"
128
+ d.mkdir()
129
+ (d / "s1.jsonl").write_text("{}")
130
+ assert cr.transcript_exists("/home/u/x", "s1", projects_dir=str(tmp_path))
131
+ assert not cr.transcript_exists("/home/u/x", "s2", projects_dir=str(tmp_path))
132
+
133
+
134
+ def test_classify_dir():
135
+ assert cr.classify_dir("/x", isdir=lambda p: True) == ("live", None, None)
136
+ assert cr.classify_dir("/r/.claude/worktrees/a1", isdir=lambda p: p == "/r") == \
137
+ ("orphan-worktree", "/r", "a1")
138
+ assert cr.classify_dir("/gone/dir", isdir=lambda p: False) == ("gone", None, None)
139
+
140
+
141
+ def test_classify_dir_worktree_repo_also_gone():
142
+ assert cr.classify_dir("/r/.claude/worktrees/a1", isdir=lambda p: False) == \
143
+ ("gone", None, None)
144
+
145
+
146
+ def _fake_session_file(dirpath, pid, sid, cwd):
147
+ (dirpath / f"{pid}.json").write_text(
148
+ json.dumps({"pid": pid, "sessionId": sid, "cwd": str(cwd)}))
149
+
150
+
151
+ def test_live_sessions(tmp_path):
152
+ sdir = tmp_path / "sessions"
153
+ sdir.mkdir()
154
+ proc = tmp_path / "proc"
155
+ proc.mkdir()
156
+ work = tmp_path / "work"
157
+ work.mkdir()
158
+ _fake_session_file(sdir, 100, "s-live", work) # live claude
159
+ p = proc / "100"
160
+ p.mkdir()
161
+ (p / "comm").write_text("claude\n")
162
+ _fake_session_file(sdir, 200, "s-stale", work) # pid not running
163
+ _fake_session_file(sdir, 300, "s-vim", work) # pid alive, not claude
164
+ p = proc / "300"
165
+ p.mkdir()
166
+ (p / "comm").write_text("vim\n")
167
+ (sdir / "400.json").write_text("not json") # malformed
168
+ busy, running = cr.live_sessions(sessions_dir=str(sdir), proc_root=str(proc))
169
+ assert busy == {os.path.realpath(str(work))}
170
+ assert running == {"s-live"}
171
+
172
+
173
+ def test_live_sessions_fallback_to_proc_scan(tmp_path):
174
+ proc = tmp_path / "proc"
175
+ proc.mkdir()
176
+ work = tmp_path / "work"
177
+ work.mkdir()
178
+ p = proc / "500"
179
+ p.mkdir()
180
+ (p / "comm").write_text("claude\n")
181
+ (p / "cwd").symlink_to(work)
182
+ busy, running = cr.live_sessions(sessions_dir=str(tmp_path / "missing"),
183
+ proc_root=str(proc))
184
+ assert busy == {os.path.realpath(str(work))}
185
+ assert running == set()
186
+
187
+
188
+ def _g(path, sessions):
189
+ return {"path": path, "last_ts": sessions[0]["ts"], "sessions": sessions}
190
+
191
+
192
+ def _s(sid, ts, display=""):
193
+ return {"session_id": sid, "ts": ts, "display": display}
194
+
195
+
196
+ def test_flatten_rows_expansion_filter_running():
197
+ g1 = _g("/p/a", [_s("s1", 2000, "x"), _s("s2", 1000, "y")])
198
+ g2 = _g("/p/b", [_s("s3", 500, "z")])
199
+ rows = cr.flatten_rows([g1, g2], expanded={"/p/a"}, filt="", home="/h",
200
+ busy=set(), running_ids={"s2"}, isdir=lambda p: True)
201
+ assert [(r["kind"], r.get("session", {}).get("session_id")) for r in rows] == [
202
+ ("dir", None), ("session", "s1"), ("session", "s2"), ("dir", None)]
203
+ assert rows[2]["running"] is True and rows[1]["running"] is False
204
+ rows = cr.flatten_rows([g1, g2], expanded={"/p/a"}, filt="B", home="/h",
205
+ busy=set(), running_ids=set(), isdir=lambda p: True)
206
+ assert len(rows) == 1 and rows[0]["group"] is g2
207
+
208
+
209
+ def test_flatten_rows_busy_and_classification():
210
+ g = _g("/r/.claude/worktrees/a1", [_s("s1", 100)])
211
+ rows = cr.flatten_rows([g], expanded={"/r/.claude/worktrees/a1"}, filt="",
212
+ home="/h", busy={"/r/.claude/worktrees/a1"},
213
+ running_ids=set(), isdir=lambda p: p == "/r")
214
+ assert rows[0]["cls"] == "orphan-worktree"
215
+ assert (rows[0]["repo"], rows[0]["name"]) == ("/r", "a1")
216
+ assert rows[0]["busy"] is True and rows[1]["busy"] is True
217
+
218
+
219
+ def test_row_spans_dir():
220
+ g = _g("/h/proj", [_s("s1", 60_000, "newest"), _s("s0", 0, "older")])
221
+ row = {"kind": "dir", "group": g, "vis_sessions": g["sessions"][1:],
222
+ "cls": "live", "repo": None, "name": None, "busy": True}
223
+ # time and prompt come from the newest VISIBLE session (s0), not s1
224
+ assert cr._row_spans(row, now_ms=120_000, home="/h") == [
225
+ (" 2m ", "time"), ("~/proj", "path"),
226
+ (" [running]", "running"), (" — older", "text")]
227
+
228
+
229
+ def test_row_spans_badges_and_session():
230
+ g = _g("/r/.claude/worktrees/a1", [_s("s1", 0, "")])
231
+ row = {"kind": "dir", "group": g, "vis_sessions": g["sessions"], "cls": "orphan-worktree",
232
+ "repo": "/r", "name": "a1", "busy": False}
233
+ assert (" [worktree gone]", "orphan") in cr._row_spans(row, 0, "/h")
234
+ row["cls"] = "gone"
235
+ assert (" [gone]", "gone") in cr._row_spans(row, 0, "/h")
236
+ srow = {"kind": "session", "group": g, "session": _s("s1", 0, ""),
237
+ "cls": "live", "repo": None, "name": None, "busy": False,
238
+ "running": True}
239
+ assert cr._row_spans(srow, 0, "/h") == [
240
+ (" ", "text"), (" 0s ", "time"),
241
+ ("(no prompt)", "text"), (" [running]", "running")]
242
+
243
+
244
+ def test_flatten_rows_show_missing_and_min_ts():
245
+ live = _g("/p/live", [_s("s1", 5000)])
246
+ orphan = _g("/r/.claude/worktrees/w1", [_s("s2", 4000)])
247
+ gone = _g("/gone/x", [_s("s3", 3000)])
248
+ isdir = lambda p: p in ("/p/live", "/r")
249
+ rows = cr.flatten_rows([live, orphan, gone], expanded=set(), filt="",
250
+ home="/h", busy=set(), running_ids=set(),
251
+ isdir=isdir, show_missing=False)
252
+ assert [r["group"] for r in rows] == [live]
253
+ rows = cr.flatten_rows([live, orphan, gone], expanded=set(), filt="",
254
+ home="/h", busy=set(), running_ids=set(),
255
+ isdir=isdir, min_ts=4000)
256
+ assert [r["group"] for r in rows] == [live, orphan] # boundary ts kept
257
+
258
+
259
+ def test_flatten_rows_filters_apply_before_cap():
260
+ gone_groups = [_g(f"/gone/{i}", [_s(f"g{i}", 10_000 - i)])
261
+ for i in range(cr.MAX_DIRS)]
262
+ live_old = _g("/p/old", [_s("old", 1)])
263
+ rows = cr.flatten_rows(gone_groups + [live_old], expanded=set(), filt="",
264
+ home="/h", busy=set(), running_ids=set(),
265
+ isdir=lambda p: p == "/p/old", show_missing=False)
266
+ assert len(rows) == 1 and rows[0]["group"] is live_old
267
+
268
+
269
+ def test_flatten_rows_prompt_text_match():
270
+ g1 = _g("/p/a", [_s("s1", 2000, "fix the parser"), _s("s2", 1000, "other")])
271
+ g2 = _g("/p/b", [_s("s3", 500, "unrelated")])
272
+ rows = cr.flatten_rows([g1, g2], expanded={"/p/a", "/p/b"}, filt="PARSER",
273
+ home="/h", busy=set(), running_ids=set(),
274
+ isdir=lambda p: True)
275
+ # dir surfaces on prompt match though its path doesn't match the filter;
276
+ # only the matching session renders; g2 is fully hidden
277
+ assert [(r["kind"], r.get("session", {}).get("session_id")) for r in rows] == [
278
+ ("dir", None), ("session", "s1")]
279
+ assert rows[0]["vis_sessions"] == [g1["sessions"][0]]
280
+
281
+
282
+ def test_flatten_rows_age_filters_sessions_and_dir():
283
+ g = _g("/p/a", [_s("new", 5000, "recent"), _s("mid", 3000, "middle"),
284
+ _s("old", 1000, "ancient")])
285
+ rows = cr.flatten_rows([g], expanded={"/p/a"}, filt="", home="/h",
286
+ busy=set(), running_ids=set(), isdir=lambda p: True,
287
+ min_ts=3000)
288
+ assert [r.get("session", {}).get("session_id") for r in rows] == [
289
+ None, "new", "mid"] # "old" hidden; boundary kept
290
+ assert rows[0]["vis_sessions"][0]["session_id"] == "new"
291
+ assert cr.flatten_rows([g], expanded=set(), filt="", home="/h",
292
+ busy=set(), running_ids=set(), isdir=lambda p: True,
293
+ min_ts=6000) == [] # no survivor -> dir hidden
294
+
295
+
296
+ def test_flatten_rows_dir_top_reflects_filter():
297
+ g = _g("/p/a", [_s("s1", 2000, "alpha"), _s("s2", 1000, "beta")])
298
+ rows = cr.flatten_rows([g], expanded=set(), filt="beta", home="/h",
299
+ busy=set(), running_ids=set(), isdir=lambda p: True)
300
+ assert rows[0]["vis_sessions"][0]["session_id"] == "s2"
reclaude-0.1.0/uv.lock ADDED
@@ -0,0 +1,156 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "colorama"
7
+ version = "0.4.6"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "exceptiongroup"
16
+ version = "1.3.1"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ dependencies = [
19
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
20
+ ]
21
+ sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
22
+ wheels = [
23
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
24
+ ]
25
+
26
+ [[package]]
27
+ name = "iniconfig"
28
+ version = "2.3.0"
29
+ source = { registry = "https://pypi.org/simple" }
30
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
31
+ wheels = [
32
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
33
+ ]
34
+
35
+ [[package]]
36
+ name = "packaging"
37
+ version = "26.2"
38
+ source = { registry = "https://pypi.org/simple" }
39
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
40
+ wheels = [
41
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
42
+ ]
43
+
44
+ [[package]]
45
+ name = "pluggy"
46
+ version = "1.6.0"
47
+ source = { registry = "https://pypi.org/simple" }
48
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
49
+ wheels = [
50
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
51
+ ]
52
+
53
+ [[package]]
54
+ name = "pygments"
55
+ version = "2.20.0"
56
+ source = { registry = "https://pypi.org/simple" }
57
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
58
+ wheels = [
59
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
60
+ ]
61
+
62
+ [[package]]
63
+ name = "pytest"
64
+ version = "9.0.3"
65
+ source = { registry = "https://pypi.org/simple" }
66
+ dependencies = [
67
+ { name = "colorama", marker = "sys_platform == 'win32'" },
68
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
69
+ { name = "iniconfig" },
70
+ { name = "packaging" },
71
+ { name = "pluggy" },
72
+ { name = "pygments" },
73
+ { name = "tomli", marker = "python_full_version < '3.11'" },
74
+ ]
75
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
76
+ wheels = [
77
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
78
+ ]
79
+
80
+ [[package]]
81
+ name = "reclaude"
82
+ version = "0.1.0"
83
+ source = { editable = "." }
84
+
85
+ [package.dev-dependencies]
86
+ dev = [
87
+ { name = "pytest" },
88
+ ]
89
+
90
+ [package.metadata]
91
+
92
+ [package.metadata.requires-dev]
93
+ dev = [{ name = "pytest" }]
94
+
95
+ [[package]]
96
+ name = "tomli"
97
+ version = "2.4.1"
98
+ source = { registry = "https://pypi.org/simple" }
99
+ sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
100
+ wheels = [
101
+ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
102
+ { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
103
+ { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
104
+ { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
105
+ { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
106
+ { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
107
+ { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
108
+ { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
109
+ { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
110
+ { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
111
+ { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
112
+ { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
113
+ { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
114
+ { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
115
+ { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
116
+ { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
117
+ { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
118
+ { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
119
+ { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
120
+ { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
121
+ { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
122
+ { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
123
+ { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
124
+ { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
125
+ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
126
+ { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
127
+ { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
128
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
129
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
130
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
131
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
132
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
133
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
134
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
135
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
136
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
137
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
138
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
139
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
140
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
141
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
142
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
143
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
144
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
145
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
146
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
147
+ ]
148
+
149
+ [[package]]
150
+ name = "typing-extensions"
151
+ version = "4.15.0"
152
+ source = { registry = "https://pypi.org/simple" }
153
+ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
154
+ wheels = [
155
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
156
+ ]